StateTimeline: Treat second time field as state endings (#84130)

This commit is contained in:
Leon Sorokin 2024-03-10 22:11:11 -05:00 committed by GitHub
parent 0b71354c8d
commit 57df3b84dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 740 additions and 14 deletions

View File

@ -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": ""
}

File diff suppressed because one or more lines are too long

View File

@ -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'),

View File

@ -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];

View File

@ -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) {

View File

@ -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', () => {

View File

@ -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 };
}