Timeline: split "periodic" mode into its own panel (#34171)

This commit is contained in:
Ryan McKinley 2021-05-17 13:00:04 -07:00 committed by GitHub
parent c630393cf4
commit de5cd4a7d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 434 additions and 56 deletions

View File

@ -110,7 +110,7 @@
}
],
"title": "State changes strings",
"type": "timeline"
"type": "state-timeline"
},
{
"datasource": null,
@ -204,7 +204,7 @@
}
],
"title": "State changes with boolean values",
"type": "timeline"
"type": "state-timeline"
},
{
"datasource": null,
@ -298,7 +298,7 @@
}
],
"title": "State changes with nulls",
"type": "timeline"
"type": "state-timeline"
},
{
"datasource": null,
@ -388,8 +388,8 @@
"stringInput": ""
}
],
"title": "\"Periodic samples\" Mode",
"type": "timeline"
"title": "Status map",
"type": "status-grid"
}
],
"refresh": false,

View File

@ -188,7 +188,7 @@
}
],
"title": "\"State changes\" Mode",
"type": "timeline"
"type": "state-timeline"
},
{
"datasource": null,
@ -253,7 +253,7 @@
}
],
"title": "\"State changes\" Mode (strings & booleans)",
"type": "timeline"
"type": "state-timeline"
},
{
"datasource": null,
@ -331,8 +331,8 @@
"stringInput": ""
}
],
"title": "\"Periodic samples\" Mode",
"type": "timeline"
"title": "Status map view",
"type": "status-grid"
}
],
"refresh": false,

View File

@ -155,7 +155,8 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined
}
// Support the standard graph span nulls field config
nullModesFrame.push(field.config.custom?.spanNulls === true ? NULL_REMOVE : NULL_EXPAND);
let spanNulls = field.config.custom?.spanNulls;
nullModesFrame.push(spanNulls === true ? NULL_REMOVE : spanNulls === -1 ? NULL_RETAIN : NULL_EXPAND);
let labels = field.labels ?? {};
if (frame.name) {
@ -285,7 +286,7 @@ export function join(tables: AlignedData[], nullModes?: number[][]) {
let yVal = ys[i];
let alignedIdx = xIdxs.get(xs[i]);
if (yVal == null) {
if (yVal === null) {
if (nullMode !== NULL_REMOVE) {
yVals[alignedIdx] = yVal;

View File

@ -274,20 +274,22 @@ func getPanelSort(id string) int {
sort = 7
case "piechart":
sort = 8
case "timeline":
case "state-timeline":
sort = 9
case "heatmap":
sort = 10
case "graph":
case "status-grid":
sort = 11
case "text":
case "graph":
sort = 12
case "alertlist":
case "text":
sort = 13
case "dashlist":
case "alertlist":
sort = 14
case "news":
case "dashlist":
sort = 15
case "news":
sort = 16
}
return sort
}

View File

@ -431,7 +431,8 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
"table",
"table-old",
"text",
"timeline",
"state-timeline",
"status-grid",
"timeseries",
"welcome",
"xychart",

View File

@ -42,7 +42,8 @@ const alertmanagerPlugin = async () =>
import * as textPanel from 'app/plugins/panel/text/module';
import * as timeseriesPanel from 'app/plugins/panel/timeseries/module';
import * as timelinePanel from 'app/plugins/panel/timeline/module';
import * as stateTimelinePanel from 'app/plugins/panel/state-timeline/module';
import * as statusGridPanel from 'app/plugins/panel/status-grid/module';
import * as graphPanel from 'app/plugins/panel/graph/module';
import * as xyChartPanel from 'app/plugins/panel/xychart/module';
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
@ -90,7 +91,8 @@ const builtInPlugins: any = {
'app/plugins/panel/text/module': textPanel,
'app/plugins/panel/timeseries/module': timeseriesPanel,
'app/plugins/panel/timeline/module': timelinePanel,
'app/plugins/panel/state-timeline/module': stateTimelinePanel,
'app/plugins/panel/status-grid/module': statusGridPanel,
'app/plugins/panel/graph/module': graphPanel,
'app/plugins/panel/xychart/module': xyChartPanel,
'app/plugins/panel/dashlist/module': dashListPanel,

View File

@ -0,0 +1,53 @@
import React, { useMemo } from 'react';
import { PanelProps } from '@grafana/data';
import { useTheme2, ZoomPlugin } from '@grafana/ui';
import { TimelineMode, TimelineOptions } from './types';
import { TimelineChart } from './TimelineChart';
import { prepareTimelineFields } from './utils';
interface TimelinePanelProps extends PanelProps<TimelineOptions> {}
/**
* @alpha
*/
export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
data,
timeRange,
timeZone,
options,
width,
height,
onChangeTimeRange,
}) => {
const theme = useTheme2();
const { frames, warn } = useMemo(() => prepareTimelineFields(data?.series, options.mergeValues ?? true), [
data,
options.mergeValues,
]);
if (!frames || warn) {
return (
<div className="panel-empty">
<p>{warn ?? 'No data found in response'}</p>
</div>
);
}
return (
<TimelineChart
theme={theme}
frames={frames}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
width={width}
height={height}
{...options}
// hardcoded
mode={TimelineMode.Changes}
>
{(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
</TimelineChart>
);
};

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,9 +1,9 @@
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { TimelinePanel } from './TimelinePanel';
import { TimelineOptions, TimelineFieldConfig, TimelineMode, defaultTimelineFieldConfig } from './types';
import { StateTimelinePanel } from './StateTimelinePanel';
import { TimelineOptions, TimelineFieldConfig, defaultPanelOptions, defaultTimelineFieldConfig } from './types';
import { BarValueVisibility } from '@grafana/ui';
export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(TimelinePanel)
export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(StateTimelinePanel)
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Color]: {
@ -41,17 +41,6 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Time
})
.setPanelOptions((builder) => {
builder
.addRadio({
path: 'mode',
name: 'Mode',
defaultValue: TimelineMode.Changes,
settings: {
options: [
{ label: 'State changes', value: TimelineMode.Changes },
{ label: 'Periodic samples', value: TimelineMode.Samples },
],
},
})
.addRadio({
path: 'showValue',
name: 'Show values',
@ -62,7 +51,7 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Time
{ value: BarValueVisibility.Never, label: 'Never' },
],
},
defaultValue: BarValueVisibility.Always,
defaultValue: defaultPanelOptions.showValue,
})
.addRadio({
path: 'alignValue',
@ -74,29 +63,22 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Time
{ value: 'right', label: 'Right' },
],
},
defaultValue: 'center',
showIf: ({ mode }) => mode === TimelineMode.Changes,
defaultValue: defaultPanelOptions.alignValue,
})
.addBooleanSwitch({
path: 'mergeValues',
name: 'Merge equal consecutive values',
defaultValue: defaultPanelOptions.mergeValues,
})
.addSliderInput({
path: 'rowHeight',
name: 'Row height',
defaultValue: 0.9,
settings: {
min: 0,
max: 1,
step: 0.01,
},
})
.addSliderInput({
path: 'colWidth',
name: 'Column width',
defaultValue: 0.9,
settings: {
min: 0,
max: 1,
step: 0.01,
},
showIf: ({ mode }) => mode === TimelineMode.Samples,
defaultValue: defaultPanelOptions.rowHeight,
});
//addLegendOptions(builder);

View File

@ -1,7 +1,7 @@
{
"type": "panel",
"name": "Timeline",
"id": "timeline",
"name": "State timeline",
"id": "state-timeline",
"state": "alpha",

View File

@ -4,12 +4,14 @@ import { VizLegendOptions, HideableFieldConfig, BarValueVisibility } from '@graf
* @alpha
*/
export interface TimelineOptions {
mode: TimelineMode;
mode: TimelineMode; // not in the saved model!
legend: VizLegendOptions;
showValue: BarValueVisibility;
rowHeight: number;
colWidth?: number;
alignValue: TimelineValueAlignment;
mergeValues?: boolean;
}
export type TimelineValueAlignment = 'center' | 'left' | 'right';
@ -22,6 +24,16 @@ export interface TimelineFieldConfig extends HideableFieldConfig {
fillOpacity?: number; // 100
}
/**
* @alpha
*/
export const defaultPanelOptions: Partial<TimelineOptions> = {
showValue: BarValueVisibility.Always,
mergeValues: true,
alignValue: 'left',
rowHeight: 0.9,
};
/**
* @alpha
*/

View File

@ -0,0 +1,60 @@
import { FieldType, toDataFrame } from '@grafana/data';
import { prepareTimelineFields } from './utils';
describe('prepare timeline graph', () => {
it('errors with no time fields', () => {
const frames = [
toDataFrame({
fields: [
{ name: 'a', values: [1, 2, 3] },
{ name: 'b', values: ['a', 'b', 'c'] },
],
}),
];
const info = prepareTimelineFields(frames, true);
expect(info.warn).toEqual('Data does not have a time field');
});
it('requires a number, string, or boolean value', () => {
const frames = [
toDataFrame({
fields: [
{ name: 'a', type: FieldType.time, values: [1, 2, 3] },
{ name: 'b', type: FieldType.other, values: [{}, {}, {}] },
],
}),
];
const info = prepareTimelineFields(frames, true);
expect(info.warn).toEqual('No graphable fields');
});
it('will merge duplicate values', () => {
const frames = [
toDataFrame({
fields: [
{ name: 'a', type: FieldType.time, values: [1, 2, 3, 4, 5, 6, 7] },
{ name: 'b', values: [1, 1, undefined, 1, 2, 2, null, 2, 3] },
],
}),
];
const info = prepareTimelineFields(frames, true);
expect(info.warn).toBeUndefined();
const out = info.frames![0];
const field = out.fields.find((f) => f.name === 'b');
expect(field?.values.toArray()).toMatchInlineSnapshot(`
Array [
1,
undefined,
undefined,
undefined,
2,
undefined,
null,
2,
3,
]
`);
});
});

View File

@ -8,6 +8,8 @@ import {
outerJoinDataFrames,
Field,
FALLBACK_COLOR,
FieldType,
ArrayVector,
} from '@grafana/data';
import { UPlotConfigBuilder, FIXED_UNIT, SeriesVisibilityChangeMode, UPlotConfigPrepFn } from '@grafana/ui';
import { TimelineCoreOptions, getConfig } from './timeline';
@ -193,3 +195,96 @@ export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
}
return names;
}
/**
* If any sequential duplicate values exist, this will return a new array
* with the future values set to undefined.
*
* in: 1, 1,undefined, 1,2, 2,null,2,3
* out: 1,undefined,undefined,undefined,2,undefined,null,2,3
*/
export function unsetSameFutureValues(values: any[]): any[] | undefined {
let prevVal = values[0];
let clone: any[] | undefined = undefined;
for (let i = 1; i < values.length; i++) {
let value = values[i];
if (value === null) {
prevVal = null;
} else {
if (value === prevVal) {
if (!clone) {
clone = [...values];
}
clone[i] = undefined;
} else if (value != null) {
prevVal = value;
}
}
}
return clone;
}
// This will return a set of frames with only graphable values included
export function prepareTimelineFields(
series: DataFrame[] | undefined,
mergeValues: boolean
): { frames?: DataFrame[]; warn?: string } {
if (!series?.length) {
return { warn: 'No data in response' };
}
let hasTimeseries = false;
const frames: DataFrame[] = [];
for (let frame of series) {
let isTimeseries = false;
let changed = false;
const fields: Field[] = [];
for (const field of frame.fields) {
switch (field.type) {
case FieldType.time:
isTimeseries = true;
hasTimeseries = true;
fields.push(field);
break;
case FieldType.number:
case FieldType.boolean:
case FieldType.string:
if (mergeValues) {
let merged = unsetSameFutureValues(field.values.toArray());
if (merged) {
fields.push({
...field,
values: new ArrayVector(merged),
});
changed = true;
continue;
}
}
fields.push(field);
break;
default:
changed = true;
}
}
if (isTimeseries && fields.length > 1) {
hasTimeseries = true;
if (changed) {
frames.push({
...frame,
fields,
});
} else {
frames.push(frame);
}
}
}
if (!hasTimeseries) {
return { warn: 'Data does not have a time field' };
}
if (!frames.length) {
return { warn: 'No graphable fields' };
}
return { frames };
}

View File

@ -1,15 +1,16 @@
import React from 'react';
import { PanelProps } from '@grafana/data';
import { useTheme2, ZoomPlugin } from '@grafana/ui';
import { TimelineOptions } from './types';
import { TimelineChart } from './TimelineChart';
import { StatusPanelOptions } from './types';
import { TimelineChart } from '../state-timeline/TimelineChart';
import { TimelineMode } from '../state-timeline/types';
interface TimelinePanelProps extends PanelProps<TimelineOptions> {}
interface TimelinePanelProps extends PanelProps<StatusPanelOptions> {}
/**
* @alpha
*/
export const TimelinePanel: React.FC<TimelinePanelProps> = ({
export const StatusGridPanel: React.FC<TimelinePanelProps> = ({
data,
timeRange,
timeZone,
@ -38,6 +39,9 @@ export const TimelinePanel: React.FC<TimelinePanelProps> = ({
width={width}
height={height}
{...options}
// hardcoded
mode={TimelineMode.Samples}
alignValue="center"
>
{(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
</TimelineChart>

View File

@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 80.09 80.09">
<defs>
<style>.cls-1{fill:#3865ab;}.cls-2{fill:#84aff1;}.cls-3{fill:url(#linear-gradient);}.cls-4{fill:url(#linear-gradient-2);}</style>
<linearGradient id="linear-gradient" y1="19.02" x2="66.08" y2="19.02" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f2cc0c" />
<stop offset="1" stop-color="#ff9830" />
</linearGradient>
<linearGradient id="linear-gradient-2" y1="54.06" x2="66.08" y2="54.06" xlink:href="#linear-gradient" />
</defs>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<rect class="cls-1" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="28.03" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="42.05" width="10.02" height="10.02" rx="1" />
<rect class="cls-3" x="56.06" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="70.08" y="14.02" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="28.03" y="28.03" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="42.05" y="28.03" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="56.06" y="28.03" width="10.02" height="10.02" rx="1" />
<rect class="cls-3" x="70.08" y="28.03" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" y="42.05" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="42.05" y="42.05" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="56.06" y="42.05" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="70.08" y="42.05" width="10.02" height="10.02" rx="1" />
<rect class="cls-3" x="14.02" y="56.06" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="14.02" y="70.08" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="28.03" y="70.08" width="10.02" height="10.02" rx="1" />
<rect class="cls-3" x="42.05" y="70.08" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="14.02" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" y="28.03" width="10.02" height="10.02" rx="1" transform="translate(38.05 28.03) rotate(90)" />
<rect class="cls-1" x="70.08" y="56.06" width="10.02" height="10.02" rx="1" transform="translate(136.16 -14.02) rotate(90)" />
<rect class="cls-1" x="70.08" width="10.02" height="10.02" rx="1" transform="translate(80.09 -70.08) rotate(90)" />
<path class="cls-1" d="M9,24H1a1,1,0,0,1-1-1V15a1,1,0,0,1,1-1H9a1,1,0,0,1,1,1v8A1,1,0,0,1,9,24Zm15-1V15a1,1,0,0,0-1-1H15a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,24,23Zm14,0V15a1,1,0,0,0-1-1H29a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,38.05,23Zm28,0V15a1,1,0,0,0-1-1h-8a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,66.08,23Zm-15,1h-8a1,1,0,0,1-1-1V15a1,1,0,0,1,1-1h8a1,1,0,0,1,1,1v8A1,1,0,0,1,51.06,24Z" />
<rect class="cls-1" x="14.02" y="28.03" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="28.03" y="56.06" width="10.02" height="10.02" rx="1" />
<path class="cls-1" d="M37.05,52.06H29a1,1,0,0,1-1-1v-8a1,1,0,0,1,1-1h8a1,1,0,0,1,1,1v8A1,1,0,0,1,37.05,52.06Zm-14,0H15a1,1,0,0,1-1-1v-8a1,1,0,0,1,1-1h8a1,1,0,0,1,1,1v8A1,1,0,0,1,23,52.06Zm-13,13v-8a1,1,0,0,0-1-1H1a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1H9A1,1,0,0,0,10,65.08Zm42,0v-8a1,1,0,0,0-1-1h-8a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,52.06,65.08Zm14,0v-8a1,1,0,0,0-1-1h-8a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,66.08,65.08Z" />
<rect class="cls-1" y="70.08" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="56.06" y="70.08" width="10.02" height="10.02" rx="1" />
<rect class="cls-1" x="70.08" y="70.08" width="10.02" height="10.02" rx="1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,78 @@
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { StatusGridPanel } from './StatusGridPanel';
import { StatusPanelOptions, StatusFieldConfig, defaultStatusFieldConfig } from './types';
import { BarValueVisibility } from '@grafana/ui';
export const plugin = new PanelPlugin<StatusPanelOptions, StatusFieldConfig>(StatusGridPanel)
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Color]: {
settings: {
byValueSupport: true,
},
defaultValue: {
mode: FieldColorModeId.PaletteClassic,
},
},
},
useCustomConfig: (builder) => {
builder
.addSliderInput({
path: 'lineWidth',
name: 'Line width',
defaultValue: defaultStatusFieldConfig.lineWidth,
settings: {
min: 0,
max: 10,
step: 1,
},
})
.addSliderInput({
path: 'fillOpacity',
name: 'Fill opacity',
defaultValue: defaultStatusFieldConfig.fillOpacity,
settings: {
min: 0,
max: 100,
step: 1,
},
});
},
})
.setPanelOptions((builder) => {
builder
.addRadio({
path: 'showValue',
name: 'Show values',
settings: {
options: [
//{ value: BarValueVisibility.Auto, label: 'Auto' },
{ value: BarValueVisibility.Always, label: 'Always' },
{ value: BarValueVisibility.Never, label: 'Never' },
],
},
defaultValue: BarValueVisibility.Always,
})
.addSliderInput({
path: 'rowHeight',
name: 'Row height',
defaultValue: 0.9,
settings: {
min: 0,
max: 1,
step: 0.01,
},
})
.addSliderInput({
path: 'colWidth',
name: 'Column width',
defaultValue: 0.9,
settings: {
min: 0,
max: 1,
step: 0.01,
},
});
//addLegendOptions(builder);
});

View File

@ -0,0 +1,19 @@
{
"type": "panel",
"name": "Status grid",
"id": "status-grid",
"state": "alpha",
"info": {
"description": "System status map",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/status.svg",
"large": "img/status.svg"
}
}
}

View File

@ -0,0 +1,27 @@
import { VizLegendOptions, HideableFieldConfig, BarValueVisibility } from '@grafana/ui';
/**
* @alpha
*/
export interface StatusPanelOptions {
legend: VizLegendOptions;
showValue: BarValueVisibility;
rowHeight: number;
colWidth?: number;
}
/**
* @alpha
*/
export interface StatusFieldConfig extends HideableFieldConfig {
lineWidth?: number; // 0
fillOpacity?: number; // 100
}
/**
* @alpha
*/
export const defaultStatusFieldConfig: StatusFieldConfig = {
lineWidth: 1,
fillOpacity: 70,
};