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 00000000000..8542c7349b6 --- /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/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 00000000000..49907cca771 --- /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 de3ee9e822e..d9fcd5ef9a5 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -105,6 +105,8 @@ "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-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'), diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index a03edb78f02..0b994b79402 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -101,7 +101,11 @@ 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 dc49d3abe3b..b0c4368f3bb 100644 --- a/public/app/core/components/GraphNG/utils.ts +++ b/public/app/core/components/GraphNG/utils.ts @@ -107,8 +107,14 @@ 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 15a291602b3..baa114f5da1 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -1,6 +1,18 @@ -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 +99,173 @@ 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 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'), + 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 0b71fb59073..731d53351b7 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,61 @@ export function prepareTimelineFields( const frames: DataFrame[] = []; for (let frame of series) { - let isTimeseries = false; + 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; - 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) { + 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 +509,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; } @@ -496,6 +545,7 @@ export function prepareTimelineFields( }, }, }; + changed = true; fields.push(field); break; default: @@ -506,11 +556,11 @@ export function prepareTimelineFields( hasTimeseries = true; if (changed) { frames.push({ - ...maybeSortedFrame, + ...frame, fields, }); } else { - frames.push(maybeSortedFrame); + frames.push(frame); } } } @@ -521,6 +571,7 @@ export function prepareTimelineFields( if (!frames.length) { return { warn: 'No graphable fields' }; } + return { frames }; }