From 91a4f15bba0a56f85f5d1e5cce5be19051069012 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 8 Mar 2024 20:45:08 -0600 Subject: [PATCH 1/6] StateTimeline: Treat second time field as state endings --- .../timeline-align-endtime.json | 271 ++++++++++++++++++ .../transformers/joinDataFrames.ts | 5 +- public/app/core/components/GraphNG/utils.ts | 11 +- .../components/TimelineChart/utils.test.ts | 77 ++++- .../core/components/TimelineChart/utils.ts | 57 +++- 5 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json diff --git a/devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json b/devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json new file mode 100644 index 000000000000..8542c7349b6f --- /dev/null +++ b/devenv/dev-dashboards/panel-timeline/timeline-align-endtime.json @@ -0,0 +1,271 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 988, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 13, + "w": 15, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"channel\",\n \"config\": {\n \"selector\": \"channel\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"name\",\n \"config\": {\n \"selector\": \"name\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"starttime\",\n \"config\": {\n \"selector\": \"starttime\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"endtime\",\n \"config\": {\n \"selector\": \"endtime\"\n },\n \"type\": \"string\"\n },\n {\n \"name\": \"duration_minutes\",\n \"config\": {\n \"selector\": \"duration_minutes\"\n },\n \"type\": \"number\"\n },\n {\n \"name\": \"state\",\n \"config\": {\n \"selector\": \"state\"\n },\n \"type\": \"string\"\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n \"Channel 1\",\n \"Channel 2\",\n \"Channel 1\",\n \"Channel 2\"\n ],\n [\n \"Event 1\",\n \"Event 2\",\n \"Event 3\",\n \"Event 4\"\n ],\n [\n \"2024-02-28T08:00:00Z\",\n \"2024-02-28T09:00:00Z\",\n \"2024-02-28T11:00:00Z\",\n \"2024-02-28T12:30:00Z\"\n ],\n [\n \"2024-02-28T10:00:00Z\",\n \"2024-02-28T10:30:00Z\",\n \"2024-02-28T14:00:00Z\",\n \"2024-02-28T13:30:00Z\"\n ],\n [\n 120,\n 90,\n 180,\n 60\n ],\n [\n \"OK\",\n \"ERROR\",\n \"NO_DATA\",\n \"WARNING\"\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Raw frames w/enums", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "channel": false, + "duration_minutes": true, + "name": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + }, + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "time", + "enumConfig": { + "text": [ + "2024-02-28T08:00:00Z", + "2024-02-28T09:00:00Z", + "2024-02-28T11:00:00Z", + "2024-02-28T12:30:00Z" + ] + }, + "targetField": "starttime" + }, + { + "destinationType": "time", + "targetField": "endtime" + }, + { + "destinationType": "enum", + "enumConfig": { + "text": [ + "OK", + "ERROR", + "NO_DATA", + "WARNING" + ] + }, + "targetField": "state" + } + ], + "fields": {} + } + }, + { + "id": "partitionByValues", + "options": { + "fields": [ + "channel" + ], + "keepFields": false, + "naming": { + "asLabels": false + } + } + } + ], + "type": "state-timeline" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 15, + "x": 0, + "y": 13 + }, + "id": 2, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "alias": "Channel 1", + "csvContent": "starttime,endtime,state\n1709107200000,1709114400000,OK\n1709118000000,1709128800000,NO_DATA", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "alias": "Channel 2", + "csvContent": "starttime,endtime,state\n1709110800000,1709116200000,ERROR\n1709123400000,1709127000000,WARNING", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "CSV content", + "type": "state-timeline" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "gdev", + "panel-tests", + "graph-ng", + "demo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2024-02-28T07:47:21.428Z", + "to": "2024-02-28T14:12:43.391Z" + }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "Panel Tests - StateTimeline - multiple frames with endTime", + "uid": "cdf3gkge5reo0f", + "version": 4, + "weekStart": "" +} \ No newline at end of file diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index a03edb78f02c..d813fbcfa1e6 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -101,7 +101,10 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { } const nullMode = - options.nullMode ?? ((field: Field) => (field.config.custom?.spanNulls === true ? NULL_REMOVE : NULL_EXPAND)); + options.nullMode ?? ((field: Field) => { + let spanNulls = field.config.custom?.spanNulls; + return spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND; + }); if (options.frames.length === 1) { let frame = options.frames[0]; diff --git a/public/app/core/components/GraphNG/utils.ts b/public/app/core/components/GraphNG/utils.ts index dc49d3abe3b7..f49a937e0d10 100644 --- a/public/app/core/components/GraphNG/utils.ts +++ b/public/app/core/components/GraphNG/utils.ts @@ -107,8 +107,15 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers // prevent minesweeper-expansion of nulls (gaps) when joining bars // since bar width is determined from the minimum distance between non-undefined values // (this strategy will still retain any original pre-join nulls, though) - nullMode: (field) => - isVisibleBarField(field) ? NULL_RETAIN : field.config.custom?.spanNulls === true ? NULL_REMOVE : NULL_EXPAND, + nullMode: (field) => { + if (isVisibleBarField(field)) { + return NULL_RETAIN; + } + + let spanNulls = field.config.custom?.spanNulls; + + return spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND; + }, }); if (alignedFrame) { diff --git a/public/app/core/components/TimelineChart/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts index 15a291602b37..6282532e149b 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -1,6 +1,8 @@ -import { createTheme, FieldType, ThresholdsMode, TimeRange, toDataFrame, dateTime, DataFrame } from '@grafana/data'; +import { createTheme, FieldType, ThresholdsMode, TimeRange, toDataFrame, dateTime, DataFrame, fieldMatchers, FieldMatcherID } from '@grafana/data'; import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema'; +import { preparePlotFrame } from '../GraphNG/utils'; + import { findNextStateIndex, fmtDuration, @@ -87,6 +89,79 @@ describe('prepare timeline graph', () => { const result = prepareTimelineFields(frames, true, timeRange, theme); expect(result.frames?.[0].fields[0].values).toEqual([1, 2, 3, 4]); }); + + it('join multiple frames with start and end time fields', () => { + const timeRange2: TimeRange = { + from: dateTime('2024-02-28T07:47:21.428Z'), + to: dateTime('2024-02-28T14:12:43.391Z'), + raw: { + from: dateTime('2024-02-28T07:47:21.428Z'), + to: dateTime('2024-02-28T14:12:43.391Z'), + }, + }; + + const frames = [ + toDataFrame({ + name: 'Channel 1', + fields: [ + { name: 'starttime', type: FieldType.time, values: [1709107200000, 1709118000000] }, + { name: 'endtime', type: FieldType.time, values: [1709114400000, 1709128800000] }, + { name: 'state', type: FieldType.string, values: ['OK', 'NO_DATA'] }, + ], + }), + toDataFrame({ + name: 'Channel 2', + fields: [ + { name: 'starttime', type: FieldType.time, values: [1709110800000, 1709123400000] }, + { name: 'endtime', type: FieldType.time, values: [1709116200000, 1709127000000] }, + { name: 'state', type: FieldType.string, values: ['ERROR', 'WARNING'] }, + ], + }), + ]; + + const info = prepareTimelineFields(frames, true, timeRange2, theme); + + + let joined = preparePlotFrame(info.frames!, { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byType).get('string'), + }, timeRange2); + + let vals = joined!.fields.map(f => f.values); + + expect(vals).toEqual([ + [ + 1709107200000, + 1709110800000, + 1709114400000, + 1709116200000, + 1709118000000, + 1709123400000, + 1709127000000, + 1709128800000, + ], + [ + "OK", + undefined, + null, + undefined, + "NO_DATA", + undefined, + undefined, + null, + ], + [ + undefined, + "ERROR", + undefined, + null, + undefined, + "WARNING", + null, + undefined, + ], + ]); + }); }); describe('findNextStateIndex', () => { diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 0b71fb59073c..dca29a75e48a 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -22,8 +22,9 @@ import { ThresholdsMode, TimeRange, cacheFieldDisplayNames, + outerJoinDataFrames, } from '@grafana/data'; -import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; +import { maybeSortFrame, NULL_RETAIN } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; import { nullToValue } from '@grafana/data/src/transformations/transformers/nulls/nullToValue'; import { @@ -445,15 +446,48 @@ export function prepareTimelineFields( const frames: DataFrame[] = []; for (let frame of series) { - let isTimeseries = false; + let startFieldIdx = frame.fields.findIndex((f) => f.type === FieldType.time); + let endFieldIdx = frame.fields.findLastIndex((f) => f.type === FieldType.time); + + let isTimeseries = startFieldIdx !== -1; let changed = false; - let maybeSortedFrame = maybeSortFrame( - frame, - frame.fields.findIndex((f) => f.type === FieldType.time) - ); + frame = maybeSortFrame(frame, startFieldIdx); + + // if we have a second time field, assume it is state end timestamps + // and insert nulls into the data at the end timestamps + if (endFieldIdx !== -1 && endFieldIdx !== startFieldIdx) { + let startFrame: DataFrame = { + ...frame, + fields: frame.fields.filter((f, i) => i !== endFieldIdx), + }; + + let endFrame: DataFrame = { + length: frame.length, + fields: [frame.fields[endFieldIdx]], + }; + + frame = outerJoinDataFrames({ + frames: [startFrame, endFrame], + keepDisplayNames: true, + nullMode: () => NULL_RETAIN + })!; + + frame.fields.forEach((f, i) => { + if (i > 0) { + let vals = f.values; + for (let i = 0; i < vals.length; i++) { + if (vals[i] == null) { + vals[i] = null; + } + } + } + }); + + changed = true; + } let nulledFrame = applyNullInsertThreshold({ - frame: maybeSortedFrame, + frame, refFieldPseudoMin: timeRange.from.valueOf(), refFieldPseudoMax: timeRange.to.valueOf(), }); @@ -462,8 +496,10 @@ export function prepareTimelineFields( changed = true; } + frame = nullToValue(nulledFrame); + const fields: Field[] = []; - for (let field of nullToValue(nulledFrame).fields) { + for (let field of frame.fields) { if (field.config.custom?.hideFrom?.viz) { continue; } @@ -506,11 +542,11 @@ export function prepareTimelineFields( hasTimeseries = true; if (changed) { frames.push({ - ...maybeSortedFrame, + ...frame, fields, }); } else { - frames.push(maybeSortedFrame); + frames.push(frame); } } } @@ -521,6 +557,7 @@ export function prepareTimelineFields( if (!frames.length) { return { warn: 'No graphable fields' }; } + return { frames }; } From 424d4335688657a041ce009269340324f141405d Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 8 Mar 2024 21:23:15 -0600 Subject: [PATCH 2/6] lint & gdev --- devenv/jsonnet/dev-dashboards.libsonnet | 1 + .../transformers/joinDataFrames.ts | 3 +- .../components/TimelineChart/utils.test.ts | 63 ++++++++----------- .../core/components/TimelineChart/utils.ts | 2 +- 4 files changed, 30 insertions(+), 39 deletions(-) diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index de3ee9e822e6..24824318371a 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -105,6 +105,7 @@ "testdata_alerts": (import '../dev-dashboards/alerting/testdata_alerts.json'), "text-options": (import '../dev-dashboards/panel-text/text-options.json'), "time_zone_support": (import '../dev-dashboards/scenarios/time_zone_support.json'), + "timeline-align-endtime": (import '../dev-dashboards/panel-timeline/timeline-align-endtime.json'), "timeline-demo": (import '../dev-dashboards/panel-timeline/timeline-demo.json'), "timeline-modes": (import '../dev-dashboards/panel-timeline/timeline-modes.json'), "timeline-thresholds-mappings": (import '../dev-dashboards/panel-timeline/timeline-thresholds-mappings.json'), diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index d813fbcfa1e6..0b994b794023 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -101,7 +101,8 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { } const nullMode = - options.nullMode ?? ((field: Field) => { + options.nullMode ?? + ((field: Field) => { let spanNulls = field.config.custom?.spanNulls; return spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND; }); diff --git a/public/app/core/components/TimelineChart/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts index 6282532e149b..8c4363bc258b 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -1,4 +1,14 @@ -import { createTheme, FieldType, ThresholdsMode, TimeRange, toDataFrame, dateTime, DataFrame, fieldMatchers, FieldMatcherID } from '@grafana/data'; +import { + createTheme, + FieldType, + ThresholdsMode, + TimeRange, + toDataFrame, + dateTime, + DataFrame, + fieldMatchers, + FieldMatcherID, +} from '@grafana/data'; import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema'; import { preparePlotFrame } from '../GraphNG/utils'; @@ -121,45 +131,24 @@ describe('prepare timeline graph', () => { const info = prepareTimelineFields(frames, true, timeRange2, theme); + let joined = preparePlotFrame( + info.frames!, + { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byType).get('string'), + }, + timeRange2 + ); - let joined = preparePlotFrame(info.frames!, { - x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), - y: fieldMatchers.get(FieldMatcherID.byType).get('string'), - }, timeRange2); - - let vals = joined!.fields.map(f => f.values); + let vals = joined!.fields.map((f) => f.values); expect(vals).toEqual([ - [ - 1709107200000, - 1709110800000, - 1709114400000, - 1709116200000, - 1709118000000, - 1709123400000, - 1709127000000, - 1709128800000, - ], - [ - "OK", - undefined, - null, - undefined, - "NO_DATA", - undefined, - undefined, - null, - ], - [ - undefined, - "ERROR", - undefined, - null, - undefined, - "WARNING", - null, - undefined, - ], + [ + 1709107200000, 1709110800000, 1709114400000, 1709116200000, 1709118000000, 1709123400000, 1709127000000, + 1709128800000, + ], + ['OK', undefined, null, undefined, 'NO_DATA', undefined, undefined, null], + [undefined, 'ERROR', undefined, null, undefined, 'WARNING', null, undefined], ]); }); }); diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index dca29a75e48a..5f6b8174ee6c 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -469,7 +469,7 @@ export function prepareTimelineFields( frame = outerJoinDataFrames({ frames: [startFrame, endFrame], keepDisplayNames: true, - nullMode: () => NULL_RETAIN + nullMode: () => NULL_RETAIN, })!; frame.fields.forEach((f, i) => { From 4559deb1a6cd0fc30c5597670a4e8a28efbf2c78 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 8 Mar 2024 21:41:08 -0600 Subject: [PATCH 3/6] use loop --- .../core/components/TimelineChart/utils.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 5f6b8174ee6c..14f751644fee 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -446,8 +446,21 @@ export function prepareTimelineFields( const frames: DataFrame[] = []; for (let frame of series) { - let startFieldIdx = frame.fields.findIndex((f) => f.type === FieldType.time); - let endFieldIdx = frame.fields.findLastIndex((f) => f.type === FieldType.time); + let startFieldIdx = -1; + let endFieldIdx = -1; + + for (let i = 0; i < frame.fields.length; i++) { + let f = frame.fields[i]; + + if (f.type === FieldType.time) { + if (startFieldIdx === -1) { + startFieldIdx = i; + } else if (endFieldIdx === -1) { + endFieldIdx = i; + break; + } + } + } let isTimeseries = startFieldIdx !== -1; let changed = false; @@ -455,7 +468,7 @@ export function prepareTimelineFields( // if we have a second time field, assume it is state end timestamps // and insert nulls into the data at the end timestamps - if (endFieldIdx !== -1 && endFieldIdx !== startFieldIdx) { + if (endFieldIdx !== -1) { let startFrame: DataFrame = { ...frame, fields: frame.fields.filter((f, i) => i !== endFieldIdx), From a572507ac051205c65b64d3e72180560ea00944a Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 8 Mar 2024 21:47:16 -0600 Subject: [PATCH 4/6] less ws --- public/app/core/components/GraphNG/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/public/app/core/components/GraphNG/utils.ts b/public/app/core/components/GraphNG/utils.ts index f49a937e0d10..b0c4368f3bbf 100644 --- a/public/app/core/components/GraphNG/utils.ts +++ b/public/app/core/components/GraphNG/utils.ts @@ -113,7 +113,6 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers } let spanNulls = field.config.custom?.spanNulls; - return spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND; }, }); From c809a09c0de931563c2851391a1735edf7598da3 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Sat, 9 Mar 2024 07:25:44 -0600 Subject: [PATCH 5/6] ensure we force NULL_RETAIN in all timeline fields --- .../components/TimelineChart/utils.test.ts | 115 ++++++++++++++++++ .../core/components/TimelineChart/utils.ts | 1 + 2 files changed, 116 insertions(+) diff --git a/public/app/core/components/TimelineChart/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts index 8c4363bc258b..baa114f5da11 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -100,6 +100,121 @@ describe('prepare timeline graph', () => { expect(result.frames?.[0].fields[0].values).toEqual([1, 2, 3, 4]); }); + it('join multiple frames with NULL_RETAIN rather than NULL_EXPAND', () => { + const timeRange2: TimeRange = { + from: dateTime('2023-10-20T05:04:00.000Z'), + to: dateTime('2023-10-20T07:22:00.000Z'), + raw: { + from: dateTime('2023-10-20T05:04:00.000Z'), + to: dateTime('2023-10-20T07:22:00.000Z'), + }, + }; + + const frames = [ + toDataFrame({ + name: 'Mix', + fields: [ + { name: 'time', type: FieldType.time, values: [1697778291972, 1697778393992, 1697778986994, 1697786485890] }, + { name: 'state', type: FieldType.string, values: ['RUN', null, 'RUN', null] }, + ], + }), + toDataFrame({ + name: 'Cook', + fields: [ + { + name: 'time', + type: FieldType.time, + values: [ + 1697779163986, 1697779921045, 1697780221094, 1697780521111, 1697781186192, 1697781786291, 1697783332361, + 1697783784395, 1697783790397, 1697784146478, 1697784517471, 1697784523487, 1697784949480, 1697785369505, + ], + }, + { + name: 'state', + type: FieldType.string, + values: [ + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'CCP', + null, + ], + }, + ], + }), + ]; + + const info = prepareTimelineFields(frames, true, timeRange2, theme); + + let joined = preparePlotFrame( + info.frames!, + { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byType).get('string'), + }, + timeRange2 + ); + + let vals = joined!.fields.map((f) => f.values); + + expect(vals).toEqual([ + [ + 1697778291972, 1697778393992, 1697778986994, 1697779163986, 1697779921045, 1697780221094, 1697780521111, + 1697781186192, 1697781786291, 1697783332361, 1697783784395, 1697783790397, 1697784146478, 1697784517471, + 1697784523487, 1697784949480, 1697785369505, 1697786485890, + ], + [ + 'RUN', + null, + 'RUN', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + null, + ], + [ + undefined, + undefined, + undefined, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'Heat', + 'Stage', + null, + 'CCP', + null, + undefined, + ], + ]); + }); + it('join multiple frames with start and end time fields', () => { const timeRange2: TimeRange = { from: dateTime('2024-02-28T07:47:21.428Z'), diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 14f751644fee..731d53351b7c 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -545,6 +545,7 @@ export function prepareTimelineFields( }, }, }; + changed = true; fields.push(field); break; default: From c79470f4a3eaf60d9ba29d50e6246587e860ea6b Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Sat, 9 Mar 2024 07:30:50 -0600 Subject: [PATCH 6/6] add gdev dash --- .../timeline-align-nulls-retain.json | 213 ++++++++++++++++++ devenv/jsonnet/dev-dashboards.libsonnet | 1 + 2 files changed, 214 insertions(+) create mode 100644 devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json diff --git a/devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json b/devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json new file mode 100644 index 000000000000..49907cca7714 --- /dev/null +++ b/devenv/dev-dashboards/panel-timeline/timeline-align-nulls-retain.json @@ -0,0 +1,213 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 993, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "light-blue", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 1, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Dose" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#289fb0", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Mix" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#d4b10b", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Cook" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#c900c3", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Int. Shear" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#a49225", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Ext. Shear" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#148dd7", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Transfer" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#01b70c", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 19, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "mergeValues": false, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "repeat": "CHANNEL", + "repeatDirection": "v", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"Dose\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Dose\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Dose\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Dose\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697781872300,\n 1697781963303,\n 1697784138453,\n 1697784160451\n ],\n [\n \"Cold Water Dosing Active (150 ltrs)\",\n null,\n \"Hot Water Dosing Active (50 ltrs)\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Mix\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Mix\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Mix\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Mix\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697778291972,\n 1697778393992,\n 1697778986994,\n 1697786485890\n ],\n [\n \"Running Constant Forward\",\n null,\n \"Running Constant Forward\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Cook\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Cook\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Cook\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Cook\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697779163986,\n 1697779921045,\n 1697780221094,\n 1697780521111,\n 1697781186192,\n 1697781786291,\n 1697783332361,\n 1697783784395,\n 1697783790397,\n 1697784146478,\n 1697784517471,\n 1697784523487,\n 1697784949480,\n 1697785369505\n ],\n [\n \"Heating to Setpoint (92c)\",\n \"Stage Time Running (5 mins)\",\n null,\n \"Heating to Setpoint (96c)\",\n \"Stage Time Running (10 mins)\",\n null,\n \"Heating to Setpoint (92c)\",\n \"Stage Time Running (0 mins)\",\n null,\n \"Heating to Setpoint (92c)\",\n \"Stage Time Running (0 mins)\",\n null,\n \"CCP in Progress (7 mins)\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Shear\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Shear\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Int. Shear\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Int. Shear\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697782100330,\n 1697782832342\n ],\n [\n \"Shearing Active (12 mins)\",\n null\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"Recirc\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Recirc\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Ext. Shear\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": []\n },\n \"data\": {\n \"values\": []\n }\n },\n {\n \"schema\": {\n \"refId\": \"Transfer\",\n \"meta\": {\n \"executedQueryString\": \"from(bucket: \\\"data\\\")\\r\\n |> range(start: 2023-10-20T05:04:00Z, stop: 2023-10-20T07:22:00Z)\\r\\n |> filter(fn: (r) => r[\\\"_field\\\"] == \\\"FactoryManager_Analogs_DB.A01C02U09.PHS.Transfer\\\")\\r\\n |> keep(columns: [\\\"_time\\\", \\\"_value\\\"])\\r\\n |> map(fn: (r) => ({ \\r\\n \\\"Transfer\\\": r._value,\\r\\n time: r._time,\\r\\n }))\\r\\n\",\n \"typeVersion\": [\n 0,\n 0\n ]\n },\n \"fields\": [\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n }\n },\n {\n \"config\": {},\n \"labels\": {},\n \"name\": \"Transfer\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1697785713869,\n 1697785753879,\n 1697785764887,\n 1697785875872,\n 1697786481929\n ],\n [\n \"Pre-Start Drain\",\n null,\n \"Build Pressure (0.6 Barg)\",\n \"Transfer in progress (0.7 Barg)\",\n \"Wait for pressure dissipation (0.2 Barg)\"\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Reproduced with embedded data", + "type": "state-timeline" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "gdev", + "panel-tests", + "graph-ng", + "demo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2023-10-20T05:04:00.000Z", + "to": "2023-10-20T07:22:00.000Z" + }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "utc", + "title": "Panel Tests - StateTimeline - multiple frames with nulls", + "uid": "edf55caay3w8wa", + "version": 4, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index 24824318371a..d9fcd5ef9a51 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -106,6 +106,7 @@ "text-options": (import '../dev-dashboards/panel-text/text-options.json'), "time_zone_support": (import '../dev-dashboards/scenarios/time_zone_support.json'), "timeline-align-endtime": (import '../dev-dashboards/panel-timeline/timeline-align-endtime.json'), + "timeline-align-nulls-retain": (import '../dev-dashboards/panel-timeline/timeline-align-nulls-retain.json'), "timeline-demo": (import '../dev-dashboards/panel-timeline/timeline-demo.json'), "timeline-modes": (import '../dev-dashboards/panel-timeline/timeline-modes.json'), "timeline-thresholds-mappings": (import '../dev-dashboards/panel-timeline/timeline-thresholds-mappings.json'),