diff --git a/devenv/dev-dashboards/panel-timeline/timeline-modes.json b/devenv/dev-dashboards/panel-timeline/timeline-modes.json index ecc1d988489..b9d0a2ba597 100644 --- a/devenv/dev-dashboards/panel-timeline/timeline-modes.json +++ b/devenv/dev-dashboards/panel-timeline/timeline-modes.json @@ -24,6 +24,10 @@ "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 70, + "lineWidth": 1 + }, "mappings": [], "thresholds": { "mode": "absolute", @@ -55,7 +59,7 @@ "displayMode": "list", "placement": "bottom" }, - "mode": "changes", + "mergeValues": true, "rowHeight": 0.9, "showValue": "always" }, @@ -187,7 +191,7 @@ "scenarioId": "manual_entry" } ], - "title": "\"State changes\" Mode", + "title": "State timeline", "type": "state-timeline" }, { @@ -197,6 +201,10 @@ "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 70, + "lineWidth": 1 + }, "mappings": [], "thresholds": { "mode": "absolute", @@ -228,7 +236,7 @@ "displayMode": "list", "placement": "bottom" }, - "mode": "changes", + "mergeValues": true, "rowHeight": 0.9, "showValue": "always" }, @@ -252,7 +260,7 @@ "stringInput": "true,null,false,null,true,false" } ], - "title": "\"State changes\" Mode (strings & booleans)", + "title": "State timeline (strings & booleans)", "type": "state-timeline" }, { @@ -262,6 +270,10 @@ "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 70, + "lineWidth": 1 + }, "mappings": [], "thresholds": { "mode": "absolute", @@ -296,7 +308,6 @@ "displayMode": "list", "placement": "bottom" }, - "mode": "samples", "rowHeight": 0.9, "showValue": "always" }, @@ -331,14 +342,17 @@ "stringInput": "" } ], - "title": "Status map view", + "title": "Status grid", "type": "status-grid" } ], "refresh": false, - "schemaVersion": 29, + "schemaVersion": 30, "style": "dark", - "tags": ["gdev", "panel-tests"], + "tags": [ + "gdev", + "panel-tests" + ], "templating": { "list": [] }, @@ -350,5 +364,5 @@ "timezone": "utc", "title": "Timeline Modes", "uid": "mIJjFy8Gz", - "version": 7 + "version": 21 } \ No newline at end of file diff --git a/public/app/plugins/panel/state-timeline/TimelineChart.tsx b/public/app/plugins/panel/state-timeline/TimelineChart.tsx index 05981419560..0d729eb4dcc 100755 --- a/public/app/plugins/panel/state-timeline/TimelineChart.tsx +++ b/public/app/plugins/panel/state-timeline/TimelineChart.tsx @@ -11,11 +11,11 @@ export interface TimelineProps extends Omit { static contextType = PanelContextRoot; diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index aea825b3fb9..e78bdf0a703 100755 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -41,12 +41,17 @@ export const plugin = new PanelPlugin(Stat }) .setPanelOptions((builder) => { builder + .addBooleanSwitch({ + path: 'mergeValues', + name: 'Merge equal consecutive values', + defaultValue: defaultPanelOptions.mergeValues, + }) .addRadio({ path: 'showValue', name: 'Show values', settings: { options: [ - //{ value: BarValueVisibility.Auto, label: 'Auto' }, + { value: BarValueVisibility.Auto, label: 'Auto' }, { value: BarValueVisibility.Always, label: 'Always' }, { value: BarValueVisibility.Never, label: 'Never' }, ], @@ -55,7 +60,7 @@ export const plugin = new PanelPlugin(Stat }) .addRadio({ path: 'alignValue', - name: 'Align value', + name: 'Align values', settings: { options: [ { value: 'left', label: 'Left' }, @@ -65,11 +70,6 @@ export const plugin = new PanelPlugin(Stat }, defaultValue: defaultPanelOptions.alignValue, }) - .addBooleanSwitch({ - path: 'mergeValues', - name: 'Merge equal consecutive values', - defaultValue: defaultPanelOptions.mergeValues, - }) .addSliderInput({ path: 'rowHeight', name: 'Row height', diff --git a/public/app/plugins/panel/state-timeline/plugin.json b/public/app/plugins/panel/state-timeline/plugin.json index 9239ef48875..82a6e07ed9e 100755 --- a/public/app/plugins/panel/state-timeline/plugin.json +++ b/public/app/plugins/panel/state-timeline/plugin.json @@ -6,7 +6,7 @@ "state": "alpha", "info": { - "description": "State over time", + "description": "State changes and durations", "author": { "name": "Grafana Labs", "url": "https://grafana.com" diff --git a/public/app/plugins/panel/state-timeline/timeline.ts b/public/app/plugins/panel/state-timeline/timeline.ts index ea88e0e6320..986c02a953d 100644 --- a/public/app/plugins/panel/state-timeline/timeline.ts +++ b/public/app/plugins/panel/state-timeline/timeline.ts @@ -9,6 +9,8 @@ import tinycolor from 'tinycolor2'; const { round, min, ceil } = Math; +const textPadding = 2; + const pxRatio = devicePixelRatio; const laneDistr = SPACE_BETWEEN; @@ -25,8 +27,6 @@ function walk(rowHeight: number, yIdx: number | null, count: number, dim: number } interface TimelineBoxRect extends Rect { - left: number; - strokeWidth: number; fillColor: string; } @@ -35,12 +35,12 @@ interface TimelineBoxRect extends Rect { */ export interface TimelineCoreOptions { mode: TimelineMode; + alignValue?: TimelineValueAlignment; numSeries: number; rowHeight: number; colWidth?: number; theme: GrafanaTheme2; showValue: BarValueVisibility; - alignValue: TimelineValueAlignment; isDiscrete: (seriesIdx: number) => boolean; getValueColor: (seriesIdx: number, value: any) => string; label: (seriesIdx: number) => string; @@ -62,10 +62,10 @@ export function getConfig(opts: TimelineCoreOptions) { rowHeight = 0, colWidth = 0, showValue, - alignValue, theme, label, formatValue, + alignValue = 'left', getTimeRange, getValueColor, getFieldConfig, @@ -150,9 +150,7 @@ export function getConfig(opts: TimelineCoreOptions) { h: boxHeight, sidx: seriesIdx + 1, didx: valueIdx, - // These two are needed for later text positioning - left: left, - strokeWidth, + // for computing label contrast fillColor, }); @@ -235,7 +233,7 @@ export function getConfig(opts: TimelineCoreOptions) { yOff, left, round(yOff + y0), - right - left - 2, + right - left, round(height), strokeWidth, iy, @@ -279,7 +277,10 @@ export function getConfig(opts: TimelineCoreOptions) { } }); - discrete && drawBoxes(u.ctx); + if (discrete) { + u.ctx.lineWidth = strokeWidth; + drawBoxes(u.ctx); + } u.ctx.restore(); } @@ -297,28 +298,15 @@ export function getConfig(opts: TimelineCoreOptions) { u.ctx.clip(); u.ctx.font = font; - u.ctx.textAlign = alignValue; + 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, - moveTo, - lineTo, - rect - ) => { + (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => { + let strokeWidth = round((series.width || 0) * pxRatio); + let y = round(yOff + yMids[sidx - 1]); for (let ix = 0; ix < dataY.length; ix++) { @@ -326,14 +314,29 @@ export function getConfig(opts: TimelineCoreOptions) { const boxRect = boxRectsBySeries[sidx - 1][ix]; // Todo refine this to better know when to not render text (when values do not fit) - if (!boxRect || boxRect.w < 20) { + if (!boxRect || (showValue === BarValueVisibility.Auto && boxRect.w < 25)) { continue; } - const x = getTextPositionOffet(boxRect, alignValue); + if (boxRect.x >= xDim) { + continue; // out of view + } + // center-aligned + let x = round(boxRect.x + xOff + boxRect.w / 2); + const txt = formatValue(sidx, dataY[ix]); + + 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(formatValue(sidx, dataY[ix]), x, y); + u.ctx.fillText(txt, x, y); } } } @@ -365,14 +368,28 @@ export function getConfig(opts: TimelineCoreOptions) { }); }; - const setCursor = (u: uPlot) => { - let cx = round(u.cursor!.left! * pxRatio); + function setHoverMark(i: number, o: Rect | null) { + let h = hoverMarks[i]; + 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; + } + + function hoverMulti(cx: number, cy: number) { for (let i = 0; i < numSeries; i++) { let found: Rect | null = null; if (cx >= 0) { - let cy = yMids[i]; + cy = yMids[i]; qt.get(cx, cy, 1, 1, (o) => { if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { @@ -381,28 +398,44 @@ export function getConfig(opts: TimelineCoreOptions) { }); } - let h = hoverMarks[i]; - if (found) { if (found !== hovered[i]) { - hovered[i] = found; - - h.style.display = ''; - h.style.left = round(found!.x / pxRatio) + 'px'; - h.style.top = round(found!.y / pxRatio) + 'px'; - h.style.width = round(found!.w / pxRatio) + 'px'; - h.style.height = round(found!.h / pxRatio) + 'px'; + setHoverMark(i, found); } } else if (hovered[i] != null) { - h.style.display = 'none'; - hovered[i] = null; + setHoverMark(i, null); } } + } + + function hoverOne(cx: number, cy: number) { + let found: Rect | null = null; + + qt.get(cx, cy, 1, 1, (o) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { + found = o; + } + }); + + if (found) { + setHoverMark(0, found); + } else if (hovered[0] != null) { + setHoverMark(0, null); + } + } + + const doHover = mode === TimelineMode.Changes ? hoverMulti : hoverOne; + + const setCursor = (u: uPlot) => { + let cx = round(u.cursor!.left! * pxRatio); + let cy = round(u.cursor!.top! * pxRatio); + doHover(cx, cy); }; // hide y crosshair & hover points const cursor: Partial = { y: false, + x: mode === TimelineMode.Changes, points: { show: false }, }; @@ -480,19 +513,6 @@ export function getConfig(opts: TimelineCoreOptions) { }; } -function getTextPositionOffet(rect: TimelineBoxRect, alignValue: TimelineValueAlignment) { - // left or right aligned values shift 2 pixels inside edge - const textPadding = alignValue === 'left' ? 2 : alignValue === 'right' ? -2 : 0; - const { left, w, strokeWidth } = rect; - - return ( - left + - strokeWidth / 2 + - (alignValue === 'center' ? w / 2 - strokeWidth / 2 : alignValue === 'right' ? w - strokeWidth / 2 : 0) + - textPadding - ); -} - function getFillColor(fieldConfig: TimelineFieldConfig, color: string) { const opacityPercent = (fieldConfig.fillOpacity ?? 100) / 100; return tinycolor(color).setAlpha(opacityPercent).toString(); diff --git a/public/app/plugins/panel/state-timeline/types.ts b/public/app/plugins/panel/state-timeline/types.ts index b0a449177f6..4c4e5d6bd46 100644 --- a/public/app/plugins/panel/state-timeline/types.ts +++ b/public/app/plugins/panel/state-timeline/types.ts @@ -9,9 +9,13 @@ export interface TimelineOptions { legend: VizLegendOptions; showValue: BarValueVisibility; rowHeight: number; + + // only used for "samples" mode (status-grid) colWidth?: number; - alignValue: TimelineValueAlignment; + // only used in "changes" mode (state-timeline) mergeValues?: boolean; + // only used in "changes" mode (state-timeline) + alignValue?: TimelineValueAlignment; } export type TimelineValueAlignment = 'center' | 'left' | 'right'; @@ -28,9 +32,9 @@ export interface TimelineFieldConfig extends HideableFieldConfig { * @alpha */ export const defaultPanelOptions: Partial = { - showValue: BarValueVisibility.Always, - mergeValues: true, + showValue: BarValueVisibility.Auto, alignValue: 'left', + mergeValues: true, rowHeight: 0.9, }; @@ -46,6 +50,8 @@ export const defaultTimelineFieldConfig: TimelineFieldConfig = { * @alpha */ export enum TimelineMode { + // state-timeline Changes = 'changes', + // status-grid Samples = 'samples', } diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts index 7c1ada74def..f7f11282e2d 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/plugins/panel/state-timeline/utils.ts @@ -250,6 +250,9 @@ export function prepareTimelineFields( case FieldType.number: case FieldType.boolean: case FieldType.string: + // magic value for join() to leave nulls alone + (field.config.custom = field.config.custom ?? {}).spanNulls = -1; + if (mergeValues) { let merged = unsetSameFutureValues(field.values.toArray()); if (merged) { diff --git a/public/app/plugins/panel/status-grid/StatusGridPanel.tsx b/public/app/plugins/panel/status-grid/StatusGridPanel.tsx index e9f837914b7..e91cb44410a 100755 --- a/public/app/plugins/panel/status-grid/StatusGridPanel.tsx +++ b/public/app/plugins/panel/status-grid/StatusGridPanel.tsx @@ -41,7 +41,6 @@ export const StatusGridPanel: React.FC = ({ {...options} // hardcoded mode={TimelineMode.Samples} - alignValue="center" > {(config) => } diff --git a/public/app/plugins/panel/status-grid/module.tsx b/public/app/plugins/panel/status-grid/module.tsx index 0be8f914145..48a9fd3bc60 100755 --- a/public/app/plugins/panel/status-grid/module.tsx +++ b/public/app/plugins/panel/status-grid/module.tsx @@ -46,12 +46,12 @@ export const plugin = new PanelPlugin(Sta name: 'Show values', settings: { options: [ - //{ value: BarValueVisibility.Auto, label: 'Auto' }, + { value: BarValueVisibility.Auto, label: 'Auto' }, { value: BarValueVisibility.Always, label: 'Always' }, { value: BarValueVisibility.Never, label: 'Never' }, ], }, - defaultValue: BarValueVisibility.Always, + defaultValue: BarValueVisibility.Auto, }) .addSliderInput({ path: 'rowHeight', diff --git a/public/app/plugins/panel/status-grid/plugin.json b/public/app/plugins/panel/status-grid/plugin.json index 7e260e54863..08046fa495e 100755 --- a/public/app/plugins/panel/status-grid/plugin.json +++ b/public/app/plugins/panel/status-grid/plugin.json @@ -6,7 +6,7 @@ "state": "alpha", "info": { - "description": "System status map", + "description": "Periodic status history", "author": { "name": "Grafana Labs", "url": "https://grafana.com"