Refactor state timeline/status history to cue model and refactor TimelineChart component (#61631)

* Adapt state timeline to scuemata

* Refactor status history to cue model

* Refactor

* Refactor TimelineChart as a core component

* wip

* Change as per CR

Co-authored-by: sam boyer <sdboyer@grafana.com>
This commit is contained in:
Victor Marin
2023-01-26 09:03:59 +02:00
committed by GitHub
parent 6c9174a766
commit 6a93c77082
28 changed files with 376 additions and 216 deletions

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data';
import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema';
import {
PanelContext,
PanelContextRoot,
GraphNG,
GraphNGProps,
UPlotConfigBuilder,
VizLayout,
VizLegend,
VizLegendItem,
} from '@grafana/ui';
import { preparePlotConfigBuilder, TimelineMode } from './utils';
/**
* @alpha
*/
export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {
mode: TimelineMode;
rowHeight?: number;
showValue: VisibilityMode;
alignValue?: TimelineValueAlignment;
colWidth?: number;
legendItems?: VizLegendItem[];
}
const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue'];
export class TimelineChart extends React.Component<TimelineProps> {
static contextType = PanelContextRoot;
panelContext: PanelContext = {} as PanelContext;
getValueColor = (frameIdx: number, fieldIdx: number, value: any) => {
const field = this.props.frames[frameIdx].fields[fieldIdx];
if (field.display) {
const disp = field.display(value); // will apply color modes
if (disp.color) {
return disp.color;
}
}
return FALLBACK_COLOR;
};
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
this.panelContext = this.context;
const { eventBus, sync } = this.panelContext;
return preparePlotConfigBuilder({
frame: alignedFrame,
getTimeRange,
eventBus,
sync,
allFrames: this.props.frames,
...this.props,
// Ensure timezones is passed as an array
timeZones: Array.isArray(this.props.timeZone) ? this.props.timeZone : [this.props.timeZone],
// When there is only one row, use the full space
rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1,
getValueColor: this.getValueColor,
});
};
renderLegend = (config: UPlotConfigBuilder) => {
const { legend, legendItems } = this.props;
if (!config || !legendItems || !legend || legend.showLegend === false) {
return null;
}
return (
<VizLayout.Legend placement={legend.placement}>
<VizLegend placement={legend.placement} items={legendItems} displayMode={legend.displayMode} readonly />
</VizLayout.Legend>
);
};
render() {
return (
<GraphNG
{...this.props}
fields={{
x: (f) => f.type === FieldType.time,
y: (f) => f.type === FieldType.number || f.type === FieldType.boolean || f.type === FieldType.string,
}}
prepConfig={this.prepConfig}
propsToDiff={propsToDiff}
renderLegend={this.renderLegend}
/>
);
}
}

View File

@@ -0,0 +1,581 @@
import uPlot, { Cursor, Series } from 'uplot';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { alpha } from '@grafana/data/src/themes/colorManipulator';
import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema';
import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG';
import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute';
import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree';
import { PanelFieldConfig as StateTimeLineFieldConfig } from 'app/plugins/panel/state-timeline/panelcfg.gen';
import { PanelFieldConfig as StatusHistoryFieldConfig } from 'app/plugins/panel/status-history/panelcfg.gen';
import { TimelineMode } from './utils';
const { round, min, ceil } = Math;
const textPadding = 2;
let pxPerChar = 6;
const laneDistr = SPACE_BETWEEN;
type WalkCb = (idx: number, offPx: number, dimPx: number) => void;
function walk(rowHeight: number, yIdx: number | null, count: number, dim: number, draw: WalkCb) {
distribute(count, rowHeight, laneDistr, yIdx, (i, offPct, dimPct) => {
let laneOffPx = dim * offPct;
let laneWidPx = dim * dimPct;
draw(i, laneOffPx, laneWidPx);
});
}
interface TimelineBoxRect extends Rect {
fillColor: string;
}
/**
* @internal
*/
export interface TimelineCoreOptions {
mode: TimelineMode;
alignValue?: TimelineValueAlignment;
numSeries: number;
rowHeight?: number;
colWidth?: number;
theme: GrafanaTheme2;
showValue: VisibilityMode;
mergeValues?: boolean;
isDiscrete: (seriesIdx: number) => boolean;
getValueColor: (seriesIdx: number, value: any) => string;
label: (seriesIdx: number) => string;
getTimeRange: () => TimeRange;
formatValue?: (seriesIdx: number, value: any) => string;
getFieldConfig: (seriesIdx: number) => StateTimeLineFieldConfig | StatusHistoryFieldConfig;
onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
onLeave: () => void;
}
/**
* @internal
*/
export function getConfig(opts: TimelineCoreOptions) {
const {
mode,
numSeries,
isDiscrete,
rowHeight = 0,
colWidth = 0,
showValue,
mergeValues = false,
theme,
label,
formatValue,
alignValue = 'left',
getTimeRange,
getValueColor,
getFieldConfig,
onHover,
onLeave,
} = opts;
let qt: Quadtree;
const hoverMarks = Array(numSeries)
.fill(null)
.map(() => {
let mark = document.createElement('div');
mark.classList.add('bar-mark');
mark.style.position = 'absolute';
mark.style.background = 'rgba(255,255,255,0.2)';
return mark;
});
// Needed for to calculate text positions
let boxRectsBySeries: TimelineBoxRect[][];
const resetBoxRectsBySeries = (count: number) => {
boxRectsBySeries = Array(numSeries)
.fill(null)
.map((v) => Array(count).fill(null));
};
const font = `500 ${Math.round(12 * devicePixelRatio)}px ${theme.typography.fontFamily}`;
const hovered: Array<Rect | null> = Array(numSeries).fill(null);
const size = [colWidth, Infinity];
const gapFactor = 1 - size[0];
const maxWidth = (size[1] ?? Infinity) * uPlot.pxRatio;
const fillPaths: Map<CanvasRenderingContext2D['fillStyle'], Path2D> = new Map();
const strokePaths: Map<CanvasRenderingContext2D['strokeStyle'], Path2D> = new Map();
function drawBoxes(ctx: CanvasRenderingContext2D) {
fillPaths.forEach((fillPath, fillStyle) => {
ctx.fillStyle = fillStyle;
ctx.fill(fillPath);
});
strokePaths.forEach((strokePath, strokeStyle) => {
ctx.strokeStyle = strokeStyle;
ctx.stroke(strokePath);
});
fillPaths.clear();
strokePaths.clear();
}
function putBox(
ctx: CanvasRenderingContext2D,
rect: uPlot.RectH,
xOff: number,
yOff: number,
left: number,
top: number,
boxWidth: number,
boxHeight: number,
strokeWidth: number,
seriesIdx: number,
valueIdx: number,
value: any,
discrete: boolean
) {
// do not render super small boxes
if (boxWidth < 1) {
return;
}
const valueColor = getValueColor(seriesIdx + 1, value);
const fieldConfig = getFieldConfig(seriesIdx);
const fillColor = getFillColor(fieldConfig, valueColor);
boxRectsBySeries[seriesIdx][valueIdx] = {
x: round(left - xOff),
y: round(top - yOff),
w: boxWidth,
h: boxHeight,
sidx: seriesIdx + 1,
didx: valueIdx,
// for computing label contrast
fillColor,
};
if (discrete) {
let fillStyle = fillColor;
let fillPath = fillPaths.get(fillStyle);
if (fillPath == null) {
fillPaths.set(fillStyle, (fillPath = new Path2D()));
}
rect(fillPath, left, top, boxWidth, boxHeight);
if (strokeWidth) {
let strokeStyle = valueColor;
let strokePath = strokePaths.get(strokeStyle);
if (strokePath == null) {
strokePaths.set(strokeStyle, (strokePath = new Path2D()));
}
rect(
strokePath,
left + strokeWidth / 2,
top + strokeWidth / 2,
boxWidth - strokeWidth,
boxHeight - strokeWidth
);
}
} else {
ctx.beginPath();
rect(ctx, left, top, boxWidth, boxHeight);
ctx.fillStyle = fillColor;
ctx.fill();
if (strokeWidth) {
ctx.beginPath();
rect(ctx, left + strokeWidth / 2, top + strokeWidth / 2, boxWidth - strokeWidth, boxHeight - strokeWidth);
ctx.strokeStyle = valueColor;
ctx.lineWidth = strokeWidth;
ctx.stroke();
}
}
}
const drawPaths: Series.PathBuilder = (u, sidx, idx0, idx1) => {
uPlot.orient(
u,
sidx,
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => {
let strokeWidth = round((series.width || 0) * uPlot.pxRatio);
let discrete = isDiscrete(sidx);
u.ctx.save();
rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
u.ctx.clip();
walk(rowHeight, sidx - 1, numSeries, yDim, (iy, y0, height) => {
if (mode === TimelineMode.Changes) {
for (let ix = 0; ix < dataY.length; ix++) {
let yVal = dataY[ix];
if (yVal != null) {
let left = Math.round(valToPosX(dataX[ix], scaleX, xDim, xOff));
let nextIx = ix;
while (
++nextIx < dataY.length &&
(dataY[nextIx] === undefined || (mergeValues && dataY[nextIx] === yVal))
) {}
// to now (not to end of chart)
let right =
nextIx === dataY.length
? xOff + xDim + strokeWidth
: Math.round(valToPosX(dataX[nextIx], scaleX, xDim, xOff));
putBox(
u.ctx,
rect,
xOff,
yOff,
left,
round(yOff + y0),
right - left,
round(height),
strokeWidth,
iy,
ix,
yVal,
discrete
);
ix = nextIx - 1;
}
}
} else if (mode === TimelineMode.Samples) {
let colWid = valToPosX(dataX[1], scaleX, xDim, xOff) - valToPosX(dataX[0], scaleX, xDim, xOff);
let gapWid = colWid * gapFactor;
let barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth);
let xShift = barWid / 2;
//let xShift = align === 1 ? 0 : align === -1 ? barWid : barWid / 2;
for (let ix = idx0; ix <= idx1; ix++) {
if (dataY[ix] != null) {
// TODO: all xPos can be pre-computed once for all series in aligned set
let left = valToPosX(dataX[ix], scaleX, xDim, xOff);
putBox(
u.ctx,
rect,
xOff,
yOff,
round(left - xShift),
round(yOff + y0),
barWid,
round(height),
strokeWidth,
iy,
ix,
dataY[ix],
discrete
);
}
}
}
});
if (discrete) {
u.ctx.lineWidth = strokeWidth;
drawBoxes(u.ctx);
}
u.ctx.restore();
}
);
return null;
};
const drawPoints: Series.Points.Show =
formatValue == null || showValue === VisibilityMode.Never
? false
: (u, sidx, i0, i1) => {
u.ctx.save();
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
u.ctx.clip();
u.ctx.font = font;
u.ctx.textAlign = mode === TimelineMode.Changes ? alignValue : 'center';
u.ctx.textBaseline = 'middle';
uPlot.orient(
u,
sidx,
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => {
let strokeWidth = round((series.width || 0) * uPlot.pxRatio);
let y = round(yOff + yMids[sidx - 1]);
for (let ix = 0; ix < dataY.length; ix++) {
if (dataY[ix] != null) {
const boxRect = boxRectsBySeries[sidx - 1][ix];
if (!boxRect || boxRect.x >= xDim) {
continue;
}
let maxChars = Math.floor(boxRect?.w / pxPerChar);
if (showValue === VisibilityMode.Auto && maxChars < 2) {
continue;
}
let txt = formatValue(sidx, dataY[ix]);
// center-aligned
let x = round(boxRect.x + xOff + boxRect.w / 2);
if (mode === TimelineMode.Changes) {
if (alignValue === 'left') {
x = round(boxRect.x + xOff + strokeWidth + textPadding);
} else if (alignValue === 'right') {
x = round(boxRect.x + xOff + boxRect.w - strokeWidth - textPadding);
}
}
// TODO: cache by fillColor to avoid setting ctx for label
u.ctx.fillStyle = theme.colors.getContrastText(boxRect.fillColor, 3);
u.ctx.fillText(txt.slice(0, maxChars), x, y);
}
}
}
);
u.ctx.restore();
return false;
};
const init = (u: uPlot) => {
let over = u.over;
let chars = '';
for (let i = 32; i <= 126; i++) {
chars += String.fromCharCode(i);
}
pxPerChar = Math.ceil((u.ctx.measureText(chars).width / chars.length) * uPlot.pxRatio);
// be a bit more conservtive to prevent overlap
pxPerChar += 2.5;
over.style.overflow = 'hidden';
hoverMarks.forEach((m) => {
over.appendChild(m);
});
};
const drawClear = (u: uPlot) => {
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear();
resetBoxRectsBySeries(u.data[0].length);
// force-clear the path cache to cause drawBars() to rebuild new quadtree
u.series.forEach((s) => {
// @ts-ignore
s._paths = null;
});
};
function setHoverMark(i: number, o: Rect | null) {
let h = hoverMarks[i];
let pxRatio = uPlot.pxRatio;
if (o) {
h.style.display = '';
h.style.left = round(o.x / pxRatio) + 'px';
h.style.top = round(o.y / pxRatio) + 'px';
h.style.width = round(o.w / pxRatio) + 'px';
h.style.height = round(o.h / pxRatio) + 'px';
} else {
h.style.display = 'none';
}
hovered[i] = o;
}
let hoveredAtCursor: Rect | undefined;
function hoverMulti(cx: number, cy: number) {
let foundAtCursor: Rect | undefined;
for (let i = 0; i < numSeries; i++) {
let found: Rect | undefined;
if (cx >= 0) {
let cy2 = yMids[i];
qt.get(cx, cy2, 1, 1, (o) => {
if (pointWithin(cx, cy2, o.x, o.y, o.x + o.w, o.y + o.h)) {
found = o;
if (Math.abs(cy - cy2) <= o.h / 2) {
foundAtCursor = o;
}
}
});
}
if (found) {
if (found !== hovered[i]) {
setHoverMark(i, found);
}
} else if (hovered[i] != null) {
setHoverMark(i, null);
}
}
if (foundAtCursor) {
if (foundAtCursor !== hoveredAtCursor) {
hoveredAtCursor = foundAtCursor;
onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
}
} else if (hoveredAtCursor) {
hoveredAtCursor = undefined;
onLeave();
}
}
function hoverOne(cx: number, cy: number) {
let foundAtCursor: Rect | undefined;
qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
foundAtCursor = o;
}
});
if (foundAtCursor) {
setHoverMark(0, foundAtCursor);
if (foundAtCursor !== hoveredAtCursor) {
hoveredAtCursor = foundAtCursor;
onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
}
} else if (hoveredAtCursor) {
setHoverMark(0, null);
hoveredAtCursor = undefined;
onLeave();
}
}
const doHover = mode === TimelineMode.Changes ? hoverMulti : hoverOne;
const setCursor = (u: uPlot) => {
let cx = round(u.cursor.left! * uPlot.pxRatio);
let cy = round(u.cursor.top! * uPlot.pxRatio);
// if quadtree is empty, fill it
if (!qt.o.length && qt.q == null) {
for (const seriesRects of boxRectsBySeries) {
for (const rect of seriesRects) {
rect && qt.add(rect);
}
}
}
doHover(cx, cy);
};
// hide y crosshair & hover points
const cursor: Partial<Cursor> = {
y: false,
x: mode === TimelineMode.Changes,
points: { show: false },
};
const yMids: number[] = Array(numSeries).fill(0);
const ySplits: number[] = Array(numSeries).fill(0);
return {
cursor,
xSplits:
mode === TimelineMode.Samples
? (u: uPlot, axisIdx: number, scaleMin: number, scaleMax: number, foundIncr: number, foundSpace: number) => {
let splits = [];
let dataIncr = u.data[0][1] - u.data[0][0];
let skipFactor = ceil(foundIncr / dataIncr);
for (let i = 0; i < u.data[0].length; i += skipFactor) {
let v = u.data[0][i];
if (v >= scaleMin && v <= scaleMax) {
splits.push(v);
}
}
return splits;
}
: null,
xRange: (u: uPlot) => {
const r = getTimeRange();
let min = r.from.valueOf();
let max = r.to.valueOf();
if (mode === TimelineMode.Samples) {
let colWid = u.data[0][1] - u.data[0][0];
let scalePad = colWid / 2;
if (min <= u.data[0][0]) {
min = u.data[0][0] - scalePad;
}
let lastIdx = u.data[0].length - 1;
if (max >= u.data[0][lastIdx]) {
max = u.data[0][lastIdx] + scalePad;
}
}
return [min, max] as uPlot.Range.MinMax;
},
ySplits: (u: uPlot) => {
walk(rowHeight, null, numSeries, u.bbox.height, (iy, y0, hgt) => {
// vertical midpoints of each series' timeline (stored relative to .u-over)
yMids[iy] = round(y0 + hgt / 2);
ySplits[iy] = u.posToVal(yMids[iy] / uPlot.pxRatio, FIXED_UNIT);
});
return ySplits;
},
yValues: (u: uPlot, splits: number[]) => splits.map((v, i) => label(i + 1)),
yRange: [0, 1] as uPlot.Range.MinMax,
// pathbuilders
drawPaths,
drawPoints,
// hooks
init,
drawClear,
setCursor,
};
}
function getFillColor(fieldConfig: { fillOpacity?: number; lineWidth?: number }, color: string) {
// if #rgba with pre-existing alpha. ignore fieldConfig.fillOpacity
// e.g. thresholds with opacity
if (color[0] === '#' && color.length === 9) {
return color;
}
const opacityPercent = (fieldConfig.fillOpacity ?? 100) / 100;
return alpha(color, opacityPercent);
}

View File

@@ -0,0 +1,283 @@
import { ArrayVector, createTheme, FieldType, ThresholdsMode, TimeRange, toDataFrame, dateTime } from '@grafana/data';
import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema';
import {
findNextStateIndex,
fmtDuration,
getThresholdItems,
prepareTimelineFields,
prepareTimelineLegendItems,
} from './utils';
const theme = createTheme();
describe('prepare timeline graph', () => {
const timeRange: TimeRange = {
from: dateTime(1),
to: dateTime(3),
raw: {
from: dateTime(1),
to: dateTime(3),
},
};
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, timeRange, theme);
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, timeRange, theme);
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, timeRange, theme);
expect(info.warn).toBeUndefined();
const out = info.frames![0];
const field = out.fields.find((f) => f.name === 'b');
expect(field?.values.toArray()).toMatchInlineSnapshot(`
[
1,
1,
undefined,
1,
2,
2,
null,
2,
3,
]
`);
});
it('should try to sort time fields', () => {
const frames = [
toDataFrame({
fields: [
{ name: 'a', type: FieldType.time, values: [4, 3, 1, 2] },
{ name: 'b', values: [1, 1, 2, 2] },
],
}),
];
const result = prepareTimelineFields(frames, true, timeRange, theme);
expect(result.frames?.[0].fields[0].values.toArray()).toEqual([1, 2, 3, 4]);
});
});
describe('findNextStateIndex', () => {
it('handles leading datapoint index', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, undefined, undefined, 2, undefined, undefined]),
config: {},
};
const result = findNextStateIndex(field, 0);
expect(result).toEqual(3);
});
it('handles trailing datapoint index', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, undefined, undefined, 2, undefined, 3]),
config: {},
};
const result = findNextStateIndex(field, 5);
expect(result).toEqual(null);
});
it('handles trailing undefined', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, undefined, undefined, 2, undefined, 3, undefined]),
config: {},
};
const result = findNextStateIndex(field, 5);
expect(result).toEqual(null);
});
it('handles datapoint index inside range', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([
1,
undefined,
undefined,
3,
undefined,
undefined,
undefined,
undefined,
2,
undefined,
undefined,
]),
config: {},
};
const result = findNextStateIndex(field, 3);
expect(result).toEqual(8);
});
describe('single data points', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, 3, 2]),
config: {},
};
test('leading', () => {
const result = findNextStateIndex(field, 0);
expect(result).toEqual(1);
});
test('trailing', () => {
const result = findNextStateIndex(field, 2);
expect(result).toEqual(null);
});
test('inside', () => {
const result = findNextStateIndex(field, 1);
expect(result).toEqual(2);
});
});
});
describe('getThresholdItems', () => {
it('should handle only one threshold', () => {
const result = getThresholdItems(
{ thresholds: { mode: ThresholdsMode.Absolute, steps: [{ color: 'black', value: 0 }] } },
theme
);
expect(result).toHaveLength(1);
});
});
describe('prepareTimelineLegendItems', () => {
it('should return legend items without crashing when single (base) threshold', () => {
const frame: any = [
{
refId: 'A',
fields: [
{
name: 'time',
config: {
color: {
mode: 'thresholds',
},
thresholds: {
mode: 'absolute',
steps: [
{
color: 'green',
value: null,
},
],
},
},
values: new ArrayVector([
1634092733455, 1634092763455, 1634092793455, 1634092823455, 1634092853455, 1634092883455, 1634092913455,
1634092943455, 1634092973455, 1634093003455,
]),
display: (value: string) => ({
text: value,
color: undefined,
numeric: NaN,
}),
},
{
name: 'A-series',
config: {
color: {
mode: 'thresholds',
},
thresholds: {
mode: 'absolute',
steps: [
{
color: 'green',
value: null,
},
],
},
},
values: new ArrayVector(['< -∞', null, null, null, null, null, null, null, null, null]),
display: (value?: string) => ({
text: value || '',
color: 'green',
numeric: NaN,
}),
},
],
},
];
const result = prepareTimelineLegendItems(
frame,
{ displayMode: LegendDisplayMode.List } as VizLegendOptions,
theme
);
expect(result).toHaveLength(1);
});
});
describe('duration', () => {
it.each`
value | expected
${-1} | ${''}
${20} | ${'20ms'}
${1000} | ${'1s'}
${1020} | ${'1s 20ms'}
${60000} | ${'1m'}
${61020} | ${'1m 1s'}
${3600000} | ${'1h'}
${6600000} | ${'1h 50m'}
${86400000} | ${'1d'}
${96640000} | ${'1d 2h'}
${604800000} | ${'1w'}
${691200000} | ${'1w 1d'}
${2419200000} | ${'4w'}
${2678400000} | ${'1mo 1d'}
${3196800000} | ${'1mo 1w'}
${3456000000} | ${'1mo 1w 3d'}
${6739200000} | ${'2mo 2w 4d'}
${31536000000} | ${'1y'}
${31968000000} | ${'1y 5d'}
${32140800000} | ${'1y 1w'}
${67910400000} | ${'2y 1mo 3w 5d'}
${40420800000} | ${'1y 3mo 1w 5d'}
${9007199254740991} | ${'285616y 5mo 1d'}
`(' function should format $value ms to $expected', ({ value, expected }) => {
const result = fmtDuration(value);
expect(result).toEqual(expected);
});
});

View File

@@ -0,0 +1,691 @@
import React from 'react';
import uPlot from 'uplot';
import {
ArrayVector,
DataFrame,
DashboardCursorSync,
DataHoverPayload,
DataHoverEvent,
DataHoverClearEvent,
FALLBACK_COLOR,
Field,
FieldColorModeId,
FieldConfig,
FieldType,
formattedValueToString,
getFieldDisplayName,
getValueFormat,
GrafanaTheme2,
getActiveThreshold,
Threshold,
getFieldConfigWithMinMax,
ThresholdsMode,
TimeRange,
} from '@grafana/data';
import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
import {
VizLegendOptions,
AxisPlacement,
ScaleDirection,
ScaleOrientation,
VisibilityMode,
TimelineValueAlignment,
HideableFieldConfig,
} from '@grafana/schema';
import {
FIXED_UNIT,
SeriesVisibilityChangeMode,
UPlotConfigBuilder,
UPlotConfigPrepFn,
VizLegendItem,
} from '@grafana/ui';
import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold';
import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue';
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
import { preparePlotData2, getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
import { getConfig, TimelineCoreOptions } from './timeline';
/**
* @internal
*/
interface UPlotConfigOptions {
frame: DataFrame;
theme: GrafanaTheme2;
mode: TimelineMode;
sync?: () => DashboardCursorSync;
rowHeight?: number;
colWidth?: number;
showValue: VisibilityMode;
alignValue?: TimelineValueAlignment;
mergeValues?: boolean;
getValueColor: (frameIdx: number, fieldIdx: number, value: any) => string;
}
/**
* @internal
*/
interface PanelFieldConfig extends HideableFieldConfig {
fillOpacity?: number;
lineWidth?: number;
}
export enum TimelineMode {
Changes = 'changes',
Samples = 'samples',
}
const defaultConfig: PanelFieldConfig = {
lineWidth: 0,
fillOpacity: 80,
};
export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return SeriesVisibilityChangeMode.AppendToSelection;
}
return SeriesVisibilityChangeMode.ToggleSelection;
}
export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ({
frame,
theme,
timeZones,
getTimeRange,
mode,
eventBus,
sync,
rowHeight,
colWidth,
showValue,
alignValue,
mergeValues,
getValueColor,
}) => {
const builder = new UPlotConfigBuilder(timeZones[0]);
const xScaleUnit = 'time';
const xScaleKey = 'x';
const isDiscrete = (field: Field) => {
const mode = field.config?.color?.mode;
return !(mode && field.display && mode.startsWith('continuous-'));
};
const getValueColorFn = (seriesIdx: number, value: any) => {
const field = frame.fields[seriesIdx];
if (
field.state?.origin?.fieldIndex !== undefined &&
field.state?.origin?.frameIndex !== undefined &&
getValueColor
) {
return getValueColor(field.state?.origin?.frameIndex, field.state?.origin?.fieldIndex, value);
}
return FALLBACK_COLOR;
};
const opts: TimelineCoreOptions = {
mode: mode!,
numSeries: frame.fields.length - 1,
isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
mergeValues,
rowHeight: rowHeight,
colWidth: colWidth,
showValue: showValue!,
alignValue,
theme,
label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
getFieldConfig: (seriesIdx) => frame.fields[seriesIdx].config.custom,
getValueColor: getValueColorFn,
getTimeRange,
// hardcoded formatter for state values
formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
onHover: (seriesIndex, valueIndex) => {
hoveredSeriesIdx = seriesIndex;
hoveredDataIdx = valueIndex;
shouldChangeHover = true;
},
onLeave: () => {
hoveredSeriesIdx = null;
hoveredDataIdx = null;
shouldChangeHover = true;
},
};
let shouldChangeHover = false;
let hoveredSeriesIdx: number | null = null;
let hoveredDataIdx: number | null = null;
const coreConfig = getConfig(opts);
const payload: DataHoverPayload = {
point: {
[xScaleUnit]: null,
[FIXED_UNIT]: null,
},
data: frame,
};
builder.addHook('init', coreConfig.init);
builder.addHook('drawClear', coreConfig.drawClear);
builder.addHook('setCursor', coreConfig.setCursor);
// in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook
// which fires after the above setCursor hook, so can take advantage of hoveringOver
// already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor
const interpolateTooltip: PlotTooltipInterpolator = (
updateActiveSeriesIdx,
updateActiveDatapointIdx,
updateTooltipPosition
) => {
if (shouldChangeHover) {
if (hoveredSeriesIdx != null) {
updateActiveSeriesIdx(hoveredSeriesIdx);
updateActiveDatapointIdx(hoveredDataIdx);
}
shouldChangeHover = false;
}
updateTooltipPosition(hoveredSeriesIdx == null);
};
builder.setTooltipInterpolator(interpolateTooltip);
builder.setPrepData((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
builder.setCursor(coreConfig.cursor);
builder.addScale({
scaleKey: xScaleKey,
isTime: true,
orientation: ScaleOrientation.Horizontal,
direction: ScaleDirection.Right,
range: coreConfig.xRange,
});
builder.addScale({
scaleKey: FIXED_UNIT, // y
isTime: false,
orientation: ScaleOrientation.Vertical,
direction: ScaleDirection.Up,
range: coreConfig.yRange,
});
builder.addAxis({
scaleKey: xScaleKey,
isTime: true,
splits: coreConfig.xSplits!,
placement: AxisPlacement.Bottom,
timeZone: timeZones[0],
theme,
grid: { show: true },
});
builder.addAxis({
scaleKey: FIXED_UNIT, // y
isTime: false,
placement: AxisPlacement.Left,
splits: coreConfig.ySplits,
values: coreConfig.yValues,
grid: { show: false },
ticks: { show: false },
gap: 16,
theme,
});
let seriesIndex = 0;
for (let i = 0; i < frame.fields.length; i++) {
if (i === 0) {
continue;
}
const field = frame.fields[i];
const config: FieldConfig<PanelFieldConfig> = field.config;
const customConfig: PanelFieldConfig = {
...defaultConfig,
...config.custom,
};
field.state!.seriesIndex = seriesIndex++;
// const scaleKey = config.unit || FIXED_UNIT;
// const colorMode = getFieldColorModeForField(field);
builder.addSeries({
scaleKey: FIXED_UNIT,
pathBuilder: coreConfig.drawPaths,
pointsBuilder: coreConfig.drawPoints,
//colorMode,
lineWidth: customConfig.lineWidth,
fillOpacity: customConfig.fillOpacity,
theme,
show: !customConfig.hideFrom?.viz,
thresholds: config.thresholds,
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
dataFrameFieldIndex: field.state?.origin,
});
}
if (sync && sync() !== DashboardCursorSync.Off) {
let cursor: Partial<uPlot.Cursor> = {};
cursor.sync = {
key: '__global_',
filters: {
pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
if (sync && sync() === DashboardCursorSync.Off) {
return false;
}
payload.rowIndex = dataIdx;
if (x < 0 && y < 0) {
payload.point[xScaleUnit] = null;
payload.point[FIXED_UNIT] = null;
eventBus.publish(new DataHoverClearEvent());
} else {
payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
payload.point.panelRelY = y > 0 ? y / h : 1; // used for old graph panel to position tooltip
payload.down = undefined;
eventBus.publish(new DataHoverEvent(payload));
}
return true;
},
},
scales: [xScaleKey, null],
};
builder.setSync();
builder.setCursor(cursor);
}
return builder;
};
export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
const names = new Map<string, number>();
for (let i = 0; i < frame.fields.length; i++) {
names.set(getFieldDisplayName(frame.fields[i], frame), i);
}
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: unknown[]): unknown[] | undefined {
let prevVal = values[0];
let clone: unknown[] | 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;
}
function getSpanNulls(field: Field) {
let spanNulls = field.config.custom?.spanNulls;
// magic value for join() to leave nulls alone instead of expanding null ranges
// should be set to -1 when spanNulls = null|undefined|false|0, which is "retain nulls, without expanding"
// Infinity is not optimal here since it causes spanNulls to be more expensive than simply removing all nulls unconditionally
return !spanNulls ? -1 : spanNulls === true ? Infinity : spanNulls;
}
/**
* Merge values by the threshold
*/
export function mergeThresholdValues(field: Field, theme: GrafanaTheme2): Field | undefined {
const thresholds = field.config.thresholds;
if (field.type !== FieldType.number || !thresholds || !thresholds.steps.length) {
return undefined;
}
const items = getThresholdItems(field.config, theme);
if (items.length !== thresholds.steps.length) {
return undefined; // should not happen
}
const thresholdToText = new Map<Threshold, string>();
const textToColor = new Map<string, string>();
for (let i = 0; i < items.length; i++) {
thresholdToText.set(thresholds.steps[i], items[i].label);
textToColor.set(items[i].label, items[i].color!);
}
let input = field.values.toArray();
const vals = new Array<String | undefined>(field.values.length);
if (thresholds.mode === ThresholdsMode.Percentage) {
const { min, max } = getFieldConfigWithMinMax(field);
const delta = max! - min!;
input = input.map((v) => {
if (v == null) {
return v;
}
return ((v - min!) / delta) * 100;
});
}
for (let i = 0; i < vals.length; i++) {
const v = input[i];
if (v == null) {
vals[i] = v;
} else {
vals[i] = thresholdToText.get(getActiveThreshold(v, thresholds.steps));
}
}
return {
...field,
config: {
...field.config,
custom: {
...field.config.custom,
spanNulls: getSpanNulls(field),
},
},
type: FieldType.string,
values: new ArrayVector(vals),
display: (value: string) => ({
text: value,
color: textToColor.get(value),
numeric: NaN,
}),
};
}
// This will return a set of frames with only graphable values included
export function prepareTimelineFields(
series: DataFrame[] | undefined,
mergeValues: boolean,
timeRange: TimeRange,
theme: GrafanaTheme2
): { 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;
let maybeSortedFrame = maybeSortFrame(
frame,
frame.fields.findIndex((f) => f.type === FieldType.time)
);
let nulledFrame = applyNullInsertThreshold({
frame: maybeSortedFrame,
refFieldPseudoMin: timeRange.from.valueOf(),
refFieldPseudoMax: timeRange.to.valueOf(),
});
if (nulledFrame !== frame) {
changed = true;
}
const fields: Field[] = [];
for (let field of nullToValue(nulledFrame).fields) {
switch (field.type) {
case FieldType.time:
isTimeseries = true;
hasTimeseries = true;
fields.push(field);
break;
case FieldType.number:
if (mergeValues && field.config.color?.mode === FieldColorModeId.Thresholds) {
const f = mergeThresholdValues(field, theme);
if (f) {
fields.push(f);
changed = true;
continue;
}
}
case FieldType.boolean:
case FieldType.string:
field = {
...field,
config: {
...field.config,
custom: {
...field.config.custom,
spanNulls: getSpanNulls(field),
},
},
};
fields.push(field);
break;
default:
changed = true;
}
}
if (isTimeseries && fields.length > 1) {
hasTimeseries = true;
if (changed) {
frames.push({
...maybeSortedFrame,
fields,
});
} else {
frames.push(maybeSortedFrame);
}
}
}
if (!hasTimeseries) {
return { warn: 'Data does not have a time field' };
}
if (!frames.length) {
return { warn: 'No graphable fields' };
}
return { frames };
}
export function getThresholdItems(fieldConfig: FieldConfig, theme: GrafanaTheme2): VizLegendItem[] {
const items: VizLegendItem[] = [];
const thresholds = fieldConfig.thresholds;
if (!thresholds || !thresholds.steps.length) {
return items;
}
const steps = thresholds.steps;
const disp = getValueFormat(thresholds.mode === ThresholdsMode.Percentage ? 'percent' : fieldConfig.unit ?? '');
const fmt = (v: number) => formattedValueToString(disp(v));
for (let i = 0; i < steps.length; i++) {
let step = steps[i];
let value = step.value;
let pre = '';
let suf = '';
if (value === -Infinity && i < steps.length - 1) {
value = steps[i + 1].value;
pre = '< ';
} else {
suf = '+';
}
items.push({
label: `${pre}${fmt(value)}${suf}`,
color: theme.visualization.getColorByName(step.color),
yAxis: 1,
});
}
return items;
}
export function prepareTimelineLegendItems(
frames: DataFrame[] | undefined,
options: VizLegendOptions,
theme: GrafanaTheme2
): VizLegendItem[] | undefined {
if (!frames || options.showLegend === false) {
return undefined;
}
return getFieldLegendItem(allNonTimeFields(frames), theme);
}
export function getFieldLegendItem(fields: Field[], theme: GrafanaTheme2): VizLegendItem[] | undefined {
if (!fields.length) {
return undefined;
}
const items: VizLegendItem[] = [];
const fieldConfig = fields[0].config;
const colorMode = fieldConfig.color?.mode ?? FieldColorModeId.Fixed;
// If thresholds are enabled show each step in the legend
if (colorMode === FieldColorModeId.Thresholds) {
return getThresholdItems(fieldConfig, theme);
}
// If thresholds are enabled show each step in the legend
if (colorMode.startsWith('continuous')) {
return undefined; // eventually a color bar
}
let stateColors: Map<string, string | undefined> = new Map();
fields.forEach((field) => {
field.values.toArray().forEach((v) => {
let state = field.display!(v);
if (state.color) {
stateColors.set(state.text, state.color!);
}
});
});
stateColors.forEach((color, label) => {
if (label.length > 0) {
items.push({
label: label!,
color: theme.visualization.getColorByName(color ?? FALLBACK_COLOR),
yAxis: 1,
});
}
});
return items;
}
function allNonTimeFields(frames: DataFrame[]): Field[] {
const fields: Field[] = [];
for (const frame of frames) {
for (const field of frame.fields) {
if (field.type !== FieldType.time) {
fields.push(field);
}
}
}
return fields;
}
export function findNextStateIndex(field: Field, datapointIdx: number) {
let end;
let rightPointer = datapointIdx + 1;
if (rightPointer >= field.values.length) {
return null;
}
const startValue = field.values.get(datapointIdx);
while (end === undefined) {
if (rightPointer >= field.values.length) {
return null;
}
const rightValue = field.values.get(rightPointer);
if (rightValue === undefined || rightValue === startValue) {
rightPointer++;
} else {
end = rightPointer;
}
}
return end;
}
/**
* Returns the precise duration of a time range passed in milliseconds.
* This function calculates with 30 days month and 365 days year.
* adapted from https://gist.github.com/remino/1563878
* @param milliSeconds The duration in milliseconds
* @returns A formated string of the duration
*/
export function fmtDuration(milliSeconds: number): string {
if (milliSeconds < 0 || Number.isNaN(milliSeconds)) {
return '';
}
let yr: number, mo: number, wk: number, d: number, h: number, m: number, s: number, ms: number;
s = Math.floor(milliSeconds / 1000);
m = Math.floor(s / 60);
s = s % 60;
h = Math.floor(m / 60);
m = m % 60;
d = Math.floor(h / 24);
h = h % 24;
yr = Math.floor(d / 365);
if (yr > 0) {
d = d % 365;
}
mo = Math.floor(d / 30);
if (mo > 0) {
d = d % 30;
}
wk = Math.floor(d / 7);
if (wk > 0) {
d = d % 7;
}
ms = Math.round((milliSeconds % 1000) * 1000) / 1000;
return (
yr > 0
? yr + 'y ' + (mo > 0 ? mo + 'mo ' : '') + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
: mo > 0
? mo + 'mo ' + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
: wk > 0
? wk + 'w ' + (d > 0 ? d + 'd ' : '')
: d > 0
? d + 'd ' + (h > 0 ? h + 'h ' : '')
: h > 0
? h + 'h ' + (m > 0 ? m + 'm ' : '')
: m > 0
? m + 'm ' + (s > 0 ? s + 's ' : '')
: s > 0
? s + 's ' + (ms > 0 ? ms + 'ms ' : '')
: ms > 0
? ms + 'ms '
: '0'
).trim();
}