diff --git a/web/src/utils/dashboard/convertBarGaugeData.spec.ts b/web/src/utils/dashboard/convertBarGaugeData.spec.ts index 670c93fbcb..f553eb0962 100644 --- a/web/src/utils/dashboard/convertBarGaugeData.spec.ts +++ b/web/src/utils/dashboard/convertBarGaugeData.spec.ts @@ -15,7 +15,14 @@ import { describe, expect, it, vi } from "vitest"; import { convertPanelData } from "./convertPanelData"; -import { barGaugeBands, barGaugeItems, convertBarGaugeData } from "./convertBarGaugeData"; +import { + barGaugeBands, + barGaugeDrilldownSeries, + barGaugeFraction, + barGaugeItems, + convertBarGaugeData, +} from "./convertBarGaugeData"; +import { calculateWidthText } from "./chartDimensionUtils"; import { chartColor } from "@/utils/chartTheme"; import { getLegendSeriesNames } from "@/composables/dashboard/useSeriesFormatting"; @@ -91,6 +98,29 @@ const rows = () => [ { x_axis_1: "/d", y_axis_1: 2 }, ]; +const twoQueryMetadata = { + queries: [0, 1].map((panelQueryIndex) => ({ ...metadata.queries[0], panelQueryIndex })), +}; +const viaTwoQueries = async (panel: any, data: any[][]) => + ( + (await convertPanelData( + panel, + data, + store, + panelRef(), + { value: null }, + { value: [] }, + twoQueryMetadata, + { height: "400px" }, + { value: [] }, + false, + )) as any + ).options; +const withSecondQuery = (panel: any, query: Record) => { + panel.queries.push({ ...panel.queries[0], ...query }); + return panel; +}; + const viaPanel = async (panel: any, data: any[]) => ( (await convertPanelData( @@ -159,7 +189,7 @@ describe("bar gauge items", () => { { type: "line", data: [] }, ], }, - sqlPanel("bar-gauge"), + sqlPanel("bar-gauge", {}, { x: [] }), ); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ name: "Requests", value: 7, min: 0, max: 50, text: "7 ms" }); @@ -179,6 +209,145 @@ describe("bar gauge items", () => { ]); }); + it("keeps a blank group name blank behind an (empty) label when the panel has a label field", () => { + const gauges = { + series: [ + { type: "gauge", min: 0, max: 50, data: [{ name: "", value: 88 }] }, + { type: "gauge", min: 0, max: 50, data: [{ name: "api", value: 7 }] }, + ], + }; + const items = barGaugeItems(gauges, sqlPanel("bar-gauge", {}, {}, fixedRange)); + expect(items.map((item) => [item.name, item.label])).toEqual([ + ["", "(empty)"], + ["api", "api"], + ]); + const unlabelled = barGaugeItems(gauges, sqlPanel("bar-gauge", {}, { x: [] })); + expect(unlabelled.find((item) => item.value === 88)).toMatchObject({ + name: "Requests", + label: "Requests", + }); + }); + + it("orders bars by name when no query sorts, whatever order the rows came back in", () => { + const gauge = (name: string, value: number) => ({ + type: "gauge", + min: 0, + max: 100, + data: [{ name, value }], + }); + const names = (series: any[], panel: any) => + barGaugeItems({ series }, panel).map((item) => item.name); + const first = [gauge("pod-10", 5), gauge("pod-2", 90), gauge("api", 40)]; + const second = [gauge("api", 40), gauge("pod-2", 90), gauge("pod-10", 5)]; + const unsorted = sqlPanel("bar-gauge"); + expect(names(first, unsorted)).toEqual(["api", "pod-2", "pod-10"]); + expect(names(second, unsorted)).toEqual(["api", "pod-2", "pod-10"]); + + const builderSort = sqlPanel("bar-gauge", {}, { y: [{ ...yField, sortBy: "DESC" }] }); + expect(names(first, builderSort)).toEqual(["pod-10", "pod-2", "api"]); + const custom = sqlPanel("bar-gauge"); + custom.queries[0].customQuery = true; + expect(names(first, custom)).toEqual(["api", "pod-2", "pod-10"]); + custom.queries[0].query = "SELECT a FROM t GROUP BY a\n ORDER BY y_axis_1 DESC"; + expect(names(first, custom)).toEqual(["pod-10", "pod-2", "api"]); + expect(names(first, promPanel("bar-gauge"))).toEqual(["pod-10", "pod-2", "api"]); + }); + + it("places a value on its own range, clamped to the track, and drills down with the real value", () => { + expect(barGaugeFraction(500, 0, 1000)).toBe(0.5); + expect(barGaugeFraction(-4184, -39912, 48088)).toBeCloseTo(35728 / 88000); + expect(barGaugeFraction(2000, 0, 1000)).toBe(1); + expect(barGaugeFraction(-5, 0, 1000)).toBe(0); + expect(barGaugeFraction(null, 0, 1000)).toBe(0); + expect(barGaugeDrilldownSeries({ name: "api", value: 0.5, data: { rawValue: 500 } })).toEqual({ + __name: "api", + __value: 500, + __axisValue: "api", + }); + }); + + it("labels a panel with a label field but no rows by its value field, not as an empty group", () => { + const items = barGaugeItems( + { series: [{ type: "gauge", min: 0, max: 100, data: [{ name: "", value: null }] }] }, + sqlPanel("bar-gauge"), + ); + expect(items[0]).toMatchObject({ name: "Requests", label: "Requests" }); + }); + + it("shows (empty) for a whitespace name and applies a display name set on the empty series", () => { + const gauges = { + series: [ + { type: "gauge", min: 0, max: 50, data: [{ name: " ", value: 3 }] }, + { type: "gauge", min: 0, max: 50, data: [{ name: "", value: 4 }] }, + ], + }; + expect(barGaugeItems(gauges, sqlPanel("bar-gauge")).map((item) => item.label)).toEqual([ + "(empty)", + "(empty)", + ]); + const renamed = { + override_config: [ + { + field: { matchBy: "series", value: "" }, + config: [{ type: "display_name", value: "No container" }], + }, + ], + }; + const labels = barGaugeItems(gauges, sqlPanel("bar-gauge", renamed)).map((item) => item.label); + expect(labels).toContain("No container"); + }); + + it("finds only a top-level ORDER BY, so a sort inside a subquery, window, literal or comment still gets name order", () => { + const gauge = (name: string) => ({ + type: "gauge", + min: 0, + max: 100, + data: [{ name, value: 1 }], + }); + const series = [gauge("c"), gauge("a"), gauge("b")]; + const order = (sql: string) => { + const panel = sqlPanel("bar-gauge"); + Object.assign(panel.queries[0], { customQuery: true, query: sql }); + return barGaugeItems({ series }, panel).map((item) => item.name); + }; + const base = "SELECT x_axis_1, y_axis_1 FROM"; + expect(order(`${base} (SELECT * FROM t ORDER BY y LIMIT 10) GROUP BY x_axis_1`)).toEqual([ + "a", + "b", + "c", + ]); + expect(order(`SELECT 'order by' AS s, x_axis_1 FROM t GROUP BY x_axis_1`)).toEqual([ + "a", + "b", + "c", + ]); + expect(order(`${base} t GROUP BY x_axis_1 -- order by y`)).toEqual(["a", "b", "c"]); + expect(order(`SELECT row_number() OVER (ORDER BY y) AS r FROM t`)).toEqual(["a", "b", "c"]); + expect(order(`${base} t GROUP BY x_axis_1 ORDER BY y_axis_1`)).toEqual(["c", "a", "b"]); + }); + + it("ignores sortBy on a derived builder field and sorts numeric names by value", () => { + const gauge = (name: string) => ({ + type: "gauge", + min: 0, + max: 100, + data: [{ name, value: 1 }], + }); + const derived = sqlPanel( + "bar-gauge", + {}, + { y: [{ ...yField, sortBy: "DESC", isDerived: true }] }, + ); + const names = ["10", "9", "-5", "1.5", "-10"].map(gauge); + expect(barGaugeItems({ series: names }, derived).map((item) => item.name)).toEqual([ + "-10", + "-5", + "1.5", + "9", + "10", + ]); + }); + it("returns no series when there are no dials", () => { expect(convertBarGaugeData({ series: [] }, sqlPanel("bar-gauge"))).toEqual({ series: [] }); }); @@ -188,14 +357,14 @@ describe("bar gauge through convertPanelData", () => { it("SQL: one horizontal gradient bar per label on the query's fixed min..max, with the unfilled track", async () => { const options = await viaPanel(sqlPanel("bar-gauge", {}, {}, fixedRange), [rows()]); const [bar] = bars(options); - expect(bar.data.map((d: any) => [d.name, d.value])).toEqual([ - ["/a", 70], - ["/b", 25], - ["/c", 3], - ["/d", 2], + expect(bar.data.map((d: any) => [d.name, d.value, d.rawValue])).toEqual([ + ["/a", 0.7, 70], + ["/b", 0.25, 25], + ["/c", 0.03, 3], + ["/d", 0.02, 2], ]); expect(options.yAxis).toMatchObject({ type: "category", inverse: true }); - expect(options.xAxis).toMatchObject({ type: "value", min: 0, max: 100, show: false }); + expect(options.xAxis).toMatchObject({ type: "value", min: 0, max: 1, show: false }); expect(bar.showBackground).toBe(true); expect(bar.data[0].itemStyle.color).toMatchObject({ type: "linear", x2: 1, y2: 0 }); expect(valueLabels(options).label.formatter({ dataIndex: 0 })).toBe("70"); @@ -205,8 +374,12 @@ describe("bar gauge through convertPanelData", () => { it("scales to the data when no range is picked, so large counts do not all fill the track", async () => { const big = rows().map((row) => ({ ...row, y_axis_1: row.y_axis_1 * 1000 })); const options = await viaPanel(sqlPanel("bar-gauge"), [big]); - expect(options.xAxis).toMatchObject({ min: 0, max: 70000 }); - expect(bars(options)[0].data.map((d: any) => d.value)).toEqual([70000, 25000, 3000, 2000]); + expect(bars(options)[0].data.map((d: any) => d.value)).toEqual([ + 1, + 25000 / 70000, + 3000 / 70000, + 2000 / 70000, + ]); }); it("reserves a name gutter as wide as the widest label, capped, so names truncate instead of clipping at the edge", async () => { @@ -225,10 +398,178 @@ describe("bar gauge through convertPanelData", () => { expect(short.yAxis.axisLabel.width).toBeLessThan(160); }); + it("fills each bar by its own query's fixed range, not one shared scale", () => { + const panel = sqlPanel("bar-gauge", {}, {}, { ...fixedRange, min: 0, max: 40000 }); + panel.queries.push({ + ...panel.queries[0], + config: { ...fixedRange, min: 0, max: 1000 }, + }); + const gauges = { + series: [ + { type: "gauge", min: 0, max: 40000, data: [{ name: "big", value: 35000 }] }, + { type: "gauge", min: 0, max: 1000, data: [{ name: "half", value: 500 }] }, + ], + }; + const options: any = convertBarGaugeData(gauges, panel, 800); + const [bar] = bars(options); + expect(bar.data.map((d: any) => [d.name, d.value])).toEqual([ + ["big", 35000 / 40000], + ["half", 0.5], + ]); + expect(valueLabels(options).label.formatter({ dataIndex: 1 })).toBe("500"); + }); + + it("draws negative values from the start of the track, so a higher value is always longer", async () => { + const negative = [ + { x_axis_1: "a", y_axis_1: -39912 }, + { x_axis_1: "b", y_axis_1: -4184 }, + { x_axis_1: "c", y_axis_1: 48088 }, + ]; + const options = await viaPanel(sqlPanel("bar-gauge", { bar_gauge_mode: "basic" }), [negative]); + const lengths = bars(options)[0].data.map((d: any) => d.value); + expect(lengths[0]).toBe(0); + expect(lengths[0]).toBeLessThan(lengths[1]); + expect(lengths[1]).toBeLessThan(lengths[2]); + expect(lengths.every((value: number) => value >= 0 && value <= 1)).toBe(true); + expect(options.xAxis).toMatchObject({ min: 0, max: 1 }); + }); + + it("sizes the value gutter to the widest value text, capped, and truncates past it", async () => { + const long = rows().map((row) => ({ ...row, y_axis_1: row.y_axis_1 * 123456789012 })); + const options = await viaPanel(sqlPanel("bar-gauge"), [long]); + const label = valueLabels(options).label; + const widest = Math.max( + ...[0, 1, 2, 3].map((i) => calculateWidthText(label.formatter({ dataIndex: i }))), + ); + expect(label).toMatchObject({ overflow: "truncate", width: widest + 2 }); + expect(options.grid.right).toBe(8 + widest + 2 + label.distance); + + const twenty = "12345678901234567890"; + const huge: any = convertBarGaugeData( + { + series: [ + { + type: "gauge", + min: 0, + max: 1, + data: [{ name: "a", value: 1, detail: { formatter: () => twenty.repeat(10) } }], + }, + ], + }, + sqlPanel("bar-gauge"), + 800, + ); + expect(valueLabels(huge).label.width).toBe(160); + expect(huge.grid.right).toBeGreaterThanOrEqual(calculateWidthText(twenty)); + }); + + it("vertical names and values truncate to the width each bar gets, narrower as bars are added", () => { + const gauges = (count: number) => ({ + series: Array.from({ length: count }, (_unused, i) => ({ + type: "gauge", + min: 0, + max: 100, + data: [{ name: `kube-prometheus-stack-operator-pod-${i}`, value: i }], + })), + }); + const panel = sqlPanel("bar-gauge", { bar_gauge_orientation: "vertical" }); + const few: any = convertBarGaugeData(gauges(4), panel, 800); + const many: any = convertBarGaugeData(gauges(16), panel, 800); + expect(few.xAxis.axisLabel).toMatchObject({ overflow: "truncate", interval: 0 }); + expect(typeof few.xAxis.axisLabel.width).toBe("number"); + expect(many.xAxis.axisLabel.width).toBeLessThan(few.xAxis.axisLabel.width); + expect(few.xAxis.axisLabel.width).toBe((800 - 16) / 4 - 4); + expect(valueLabels(many).label.width).toBe(many.xAxis.axisLabel.width); + expect(many.tooltip.formatter({ dataIndex: 3 })).toContain( + "kube-prometheus-stack-operator-pod-3", + ); + }); + + it("two SQL queries keep their own fixed ranges, their own label fields and their own order", async () => { + const panel = withSecondQuery(sqlPanel("bar-gauge", {}, {}, { ...fixedRange, max: 40000 }), { + fields: { ...sqlPanel("bar-gauge").queries[0].fields, x: [] }, + config: { ...fixedRange, min: 0, max: 1000 }, + }); + const options = await viaTwoQueries(panel, [ + [ + { x_axis_1: "zeta", y_axis_1: 35000 }, + { x_axis_1: "", y_axis_1: 88 }, + { x_axis_1: "alpha", y_axis_1: 4000 }, + ], + [{ y_axis_1: 500 }], + ]); + const [bar] = bars(options); + expect(bar.data.map((d: any) => [d.name, d.value])).toEqual([ + ["", 88 / 40000], + ["alpha", 0.1], + ["zeta", 35000 / 40000], + ["Requests", 0.5], + ]); + expect(options.yAxis.axisLabel.formatter("", 0)).toBe("(empty)"); + expect(valueLabels(options).label.formatter({ dataIndex: 3 })).toBe("500"); + }); + + it("gradient stops of a value past max sit where the steps fall on the full track", () => { + const gauges = { + series: [{ type: "gauge", min: 0, max: 1000, data: [{ name: "a", value: 1500 }] }], + }; + const steps = { + ...thresholds, + steps: [ + { value: 300, color: "#ff0000" }, + { value: 700, color: "#00ff00" }, + ], + }; + const options: any = convertBarGaugeData( + gauges, + sqlPanel("bar-gauge", { thresholds: steps }), + 800, + ); + const offsets = bars(options)[0].data[0].itemStyle.color.colorStops.map( + (stop: any) => stop.offset, + ); + expect(offsets).toEqual([0, 0.3, 0.7, 1]); + }); + + it("caps both horizontal gutters by a share of a narrow panel, so the track keeps its room", () => { + const gauges = { + series: [ + { + type: "gauge", + min: 0, + max: 1, + data: [{ name: "n".repeat(400), value: 1, detail: { formatter: () => "9".repeat(400) } }], + }, + ], + }; + const options: any = convertBarGaugeData(gauges, sqlPanel("bar-gauge"), 300); + expect(options.yAxis.axisLabel.width).toBe(90); + expect(valueLabels(options).label.width).toBe(75); + expect(300 - options.grid.left - options.grid.right).toBeGreaterThan(100); + }); + + it("vertical labels thin out once a bar's slot is too narrow for an ellipsis, and value text hides", () => { + const gauges = { + series: Array.from({ length: 40 }, (_unused, i) => ({ + type: "gauge", + min: 0, + max: 100, + data: [{ name: `pod-${i}`, value: i }], + })), + }; + const panel = sqlPanel("bar-gauge", { bar_gauge_orientation: "vertical" }); + const crowded: any = convertBarGaugeData(gauges, panel, 400); + expect(crowded.xAxis.axisLabel).toMatchObject({ interval: "auto", width: 24 }); + expect(valueLabels(crowded).label.show).toBe(false); + const roomy: any = convertBarGaugeData(gauges, panel, 4000); + expect(roomy.xAxis.axisLabel.interval).toBe(0); + expect(valueLabels(roomy).label.show).toBe(true); + }); + it("puts the value text past the end of the track, never on the fill", async () => { const options = await viaPanel(sqlPanel("bar-gauge", {}, {}, fixedRange), [rows()]); const labels = valueLabels(options); - expect(labels.data).toEqual([100, 100, 100, 100]); + expect(labels.data).toEqual([1, 1, 1, 1]); expect(labels.itemStyle.color).toBe("transparent"); expect(labels.label.position).toBe("right"); expect(bars(options)[0].label).toBeUndefined(); @@ -268,9 +609,10 @@ describe("bar gauge through convertPanelData", () => { expect(series[0].data[0].itemStyle.color).toBe(chartColor("--color-border-subtle")); const lit = series.slice(1); expect(lit.map((s: any) => s.z)).toEqual([6, 5, 4]); - expect(lit.map((s: any) => s.data[0].value)).toEqual([20, 60, 70]); + expect(lit.map((s: any) => s.data[0].value)).toEqual([0.2, 0.6, 0.7]); expect(lit[2].data[1]).toBe("-"); - expect(lit.every((s: any) => s.symbolClip && s.symbolBoundingData === 100)).toBe(true); + expect(series[0].data[0].value).toBe(1); + expect(lit.every((s: any) => s.symbolClip && s.symbolBoundingData === 1)).toBe(true); expect(bars(options)[0].data[0].itemStyle.color).toBe("transparent"); expect(bars(options)[0].showBackground).toBeUndefined(); }); @@ -333,7 +675,7 @@ describe("bar gauge through convertPanelData", () => { it("PromQL: one bar per series with its legend name and latest value", async () => { const options = await viaPanel(promPanel("bar-gauge"), promData()); - expect(bars(options)[0].data.map((d: any) => [d.name, d.value])).toEqual([ + expect(bars(options)[0].data.map((d: any) => [d.name, d.rawValue])).toEqual([ ["api", 70], ["web", 25], ["db", 3], diff --git a/web/src/utils/dashboard/convertBarGaugeData.ts b/web/src/utils/dashboard/convertBarGaugeData.ts index cfe2875baa..8d6d5758c5 100644 --- a/web/src/utils/dashboard/convertBarGaugeData.ts +++ b/web/src/utils/dashboard/convertBarGaugeData.ts @@ -35,11 +35,18 @@ export const DEFAULT_BAR_GAUGE_ORIENTATION = "horizontal"; const GRADIENT_START_ALPHA = 0.35; const LCD_CELL_LENGTH = 6; const LCD_CELL_GAP = 2; -const VALUE_LABEL_ROOM = 64; +const VALUE_LABEL_MAX = 160; +const VALUE_LABEL_DISTANCE = 5; const NAME_LABEL_MAX = 160; +const NAME_LABEL_SHARE = 0.3; +const VALUE_LABEL_SHARE = 0.25; const NAME_LABEL_MARGIN = 8; const GRID_EDGE = 8; const BAR_CATEGORY_GAP = "30%"; +const VERTICAL_LABEL_GAP = 4; +const VERTICAL_LABEL_MIN = 24; +const FALLBACK_PANEL_WIDTH = 800; +const EMPTY_NAME_LABEL = "(empty)"; /** One bar: a reduced series value on its own min..max range, in the colour the gauge converter gave it. */ export interface BarGaugeItem { @@ -66,11 +73,54 @@ const toNumber = (value: unknown): number | null => { return Number.isFinite(numeric) ? numeric : null; }; +const hasTopLevelOrderBy = (sql: string): boolean => { + const code = sql.replace(/'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g, " "); + const orderBy = /order\s+by\b/iy; + let depth = 0; + for (let i = 0; i < code.length; i++) { + if (code[i] === "(") depth++; + else if (code[i] === ")") depth--; + else if (depth === 0 && !/\w/.test(code[i - 1] ?? "")) { + orderBy.lastIndex = i; + if (orderBy.test(code)) return true; + } + } + return false; +}; + +// Derived builder fields never reach ORDER BY, so their sortBy orders nothing. +const queryHasSort = (query: any): boolean => + query?.customQuery + ? hasTopLevelOrderBy(String(query?.query ?? "")) + : [...(query?.fields?.x ?? []), ...(query?.fields?.y ?? [])].some( + (field: any) => field?.sortBy && !field?.isDerived, + ); + +const compareNames = (a: string, b: string): number => { + const numeric = a.trim() !== "" && b.trim() !== "" && Number.isFinite(+a) && Number.isFinite(+b); + return numeric ? +a - +b : a.localeCompare(b, undefined, { numeric: true }); +}; + /** A bar gauge scales to its data unless the user picked a fixed range. */ export const isBarGaugeAutoRange = ( queryConfig: { range_mode?: string | null } | null | undefined, ) => queryConfig?.range_mode !== "fixed"; +/** Where a value sits on its own min..max, 0 at min and 1 at max, so each bar fills by its own query's range. */ +export const barGaugeFraction = (value: number | null, min: number, max: number): number => { + if (value === null) return 0; + const span = max - min; + if (!(span > 0)) return value >= max ? 1 : 0; + return Math.min(1, Math.max(0, (value - min) / span)); +}; + +/** The drilldown variables of a clicked bar, carrying its real value rather than the 0..1 fill it is drawn at. */ +export const barGaugeDrilldownSeries = (params: any) => ({ + __name: params?.name, + __value: params?.data?.rawValue, + __axisValue: params?.name, +}); + /** The panel as a gauge for the gauge converters, with each query's unset range resolved to auto. */ export const barGaugeSchemaAsGauge = (panelSchema: any) => ({ ...panelSchema, @@ -82,20 +132,23 @@ export const barGaugeSchemaAsGauge = (panelSchema: any) => ({ ), }); -/** The bars behind gauge options: one per dial, named by its label or else the value field. */ +/** The bars behind gauge options: one per dial, named by its label or else its query's value field. */ export const barGaugeItems = (gaugeOptions: any, panelSchema: any): BarGaugeItem[] => { - const query = panelSchema?.queries?.[0]; - const yField = query?.fields?.y?.[0]; - const fallbackName = - getFieldLabel(yField, panelSchema?.config, query?.customQuery) || yField?.alias || ""; + const queries: any[] = panelSchema?.queries ?? []; + const promql = panelSchema?.queryType === "promql"; + const fallbackNames = queries.map((query) => { + const yField = query?.fields?.y?.[0]; + return getFieldLabel(yField, panelSchema?.config, query?.customQuery) || yField?.alias || ""; + }); const noValue = String(panelSchema?.config?.no_value_replacement ?? ""); const series: any[] = Array.isArray(gaugeOptions?.series) ? gaugeOptions.series : []; const overrides = createSeriesOverrideLookup(panelSchema?.config); // Silent gauges are the display-name title overlays the gauge overrides add, not dials. - return series + const dials = series .filter((gauge) => gauge?.type === "gauge" && !gauge.silent && gauge?.data?.[0]) - .map((gauge, index) => { + .map((gauge) => { const item = gauge.data[0]; + const queryIndex = gauge._queryIndex ?? 0; const value = toNumber(item.value); const formatter = item.detail?.formatter; const text = @@ -104,17 +157,38 @@ export const barGaugeItems = (gaugeOptions: any, panelSchema: any): BarGaugeItem : typeof formatter === "function" ? String(formatter(value)) : String(value); - const name = String(item.name || fallbackName); + // A blank name with a value is an empty group; without a label field, or with no rows at all, it is the value field. + const hasLabelField = !promql && queries[queryIndex]?.fields?.x?.length > 0; + const blankIsGroup = hasLabelField && value !== null; + const name = + String(item.name ?? "") || (blankIsGroup ? "" : (fallbackNames[queryIndex] ?? "")); + const shown = String(overrides.displayName(name)); return { - name, - label: String(overrides.displayName(name)), - value, - min: toNumber(gauge.min) ?? 0, - max: toNumber(gauge.max) ?? 100, - color: item.itemStyle?.color || echartsPaletteColor(index), - text, + queryIndex, + item: { + name, + label: shown.trim() ? shown : EMPTY_NAME_LABEL, + value, + min: toNumber(gauge.min) ?? 0, + max: toNumber(gauge.max) ?? 100, + color: item.itemStyle?.color as string | undefined, + text, + }, }; }); + // An aggregate without ORDER BY comes back in any order, so name order keeps a refreshing panel steady. + const keepOrder = queries.map((query) => promql || queryHasSort(query)); + dials.sort((a, b) => + a.queryIndex !== b.queryIndex + ? a.queryIndex - b.queryIndex + : keepOrder[a.queryIndex] + ? 0 + : compareNames(a.item.name, b.item.name), + ); + return dials.map(({ item }, index) => ({ + ...item, + color: item.color || echartsPaletteColor(index), + })); }; /** The colour bands of one bar's range from its threshold steps; the part below the first step takes the base colour. */ @@ -143,7 +217,8 @@ const gradientFill = ( bands: BarGaugeBand[], horizontal: boolean, ): Record => { - const value = item.value ?? item.min; + // The bar stops at the track ends, so the stops sit on the value clamped to the range. + const value = Math.min(Math.max(item.value ?? item.min, item.min), item.max); const span = value - item.min; const shown = bands.filter((band) => band.from < value || band.from === item.min); const colorStops = @@ -166,7 +241,6 @@ const lcdSeries = ( bandsByItem: BarGaugeBand[][], horizontal: boolean, showUnfilled: boolean, - max: number, ) => { const cell = { type: "pictorialBar", @@ -175,7 +249,7 @@ const lcdSeries = ( symbolMargin: LCD_CELL_GAP, symbolSize: horizontal ? [LCD_CELL_LENGTH, "70%"] : ["70%", LCD_CELL_LENGTH], symbolClip: true, - symbolBoundingData: max, + symbolBoundingData: 1, barGap: "-100%", barCategoryGap: BAR_CATEGORY_GAP, silent: true, @@ -191,7 +265,7 @@ const lcdSeries = ( const range = bandsByItem[index][band]; if (!range || item.value === null || item.value <= range.from) return "-"; return { - value: Math.min(item.value, range.to), + value: barGaugeFraction(Math.min(item.value, range.to), item.min, item.max), itemStyle: { color: range.color }, }; }), @@ -202,7 +276,7 @@ const lcdSeries = ( ...cell, z: 2, data: items.map(() => ({ - value: max, + value: 1, itemStyle: { color: chartColor("--color-border-subtle") }, })), }, @@ -212,7 +286,7 @@ const lcdSeries = ( }; /** Converts gauge-converted options into a bar gauge: one bar per dial, coloured by its thresholds. */ -export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any) => { +export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any, panelWidth?: number) => { const config = panelSchema?.config ?? {}; const items = barGaugeItems(gaugeOptions, panelSchema); if (!items.length) return { series: [] }; @@ -221,8 +295,6 @@ export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any) => { : DEFAULT_BAR_GAUGE_MODE; const horizontal = (config.bar_gauge_orientation ?? DEFAULT_BAR_GAUGE_ORIENTATION) !== "vertical"; const showUnfilled = config.bar_gauge_show_unfilled !== false; - const min = Math.min(...items.map((item) => item.min)); - const max = Math.max(...items.map((item) => item.max)); const bandsByItem = items.map((item, index) => { const thresholds = getSeriesThresholds(config, item.name); const resolved = thresholds @@ -243,7 +315,8 @@ export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any) => { : {}), data: items.map((item, index) => ({ name: item.name, - value: item.value ?? min, + value: barGaugeFraction(item.value, item.min, item.max), + rawValue: item.value ?? item.min, itemStyle: { color: lcd ? "transparent" @@ -254,6 +327,23 @@ export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any) => { })), }; + // Vertical labels share the panel width, one slot per bar, so long names truncate instead of running together. + const width = panelWidth || FALLBACK_PANEL_WIDTH; + const slotWidth = (width - 2 * GRID_EDGE) / items.length - VERTICAL_LABEL_GAP; + // Below this width ECharts cuts text without an ellipsis, so labels thin out instead of shrinking further. + const crowded = !horizontal && slotWidth < VERTICAL_LABEL_MIN; + const labelWidth = Math.max(slotWidth, VERTICAL_LABEL_MIN); + // Both horizontal gutters are capped by a share of the panel, so a narrow panel keeps room for the track. + const gutter = (texts: string[], max: number, share: number) => + Math.min(max, width * share, Math.max(...texts.map((text) => calculateWidthText(text))) + 2); + const valueWidth = horizontal + ? gutter( + items.map((item) => item.text), + VALUE_LABEL_MAX, + VALUE_LABEL_SHARE, + ) + : labelWidth; + // The value text sits past the end of the track, so it never lands on the fill whatever the bar's length. const valueLabels = { type: "bar", @@ -265,18 +355,25 @@ export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any) => { tooltip: { show: false }, itemStyle: { color: "transparent" }, label: { - show: true, + show: !crowded, position: horizontal ? "right" : "top", + distance: VALUE_LABEL_DISTANCE, + width: valueWidth, + overflow: "truncate", color: chartColor("--color-text-heading"), formatter: (params: any) => items[params.dataIndex]?.text ?? "", }, - data: items.map(() => max), + data: items.map(() => 1), }; // Horizontal names get a gutter sized to the widest one, so a late web font truncates them instead of clipping at the edge. const nameWidth = horizontal - ? Math.min(NAME_LABEL_MAX, Math.max(...items.map((item) => calculateWidthText(item.label))) + 2) - : undefined; + ? gutter( + items.map((item) => item.label), + NAME_LABEL_MAX, + NAME_LABEL_SHARE, + ) + : labelWidth; const categoryAxis = { type: "category", data: items.map((item) => item.name), @@ -288,18 +385,18 @@ export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any) => { overflow: "truncate", width: nameWidth, margin: NAME_LABEL_MARGIN, - interval: 0, + interval: crowded ? "auto" : 0, formatter: (_value: string, index: number) => items[index]?.label ?? "", }, }; - const valueAxis = { type: "value", min, max, show: false }; + const valueAxis = { type: "value", min: 0, max: 1, show: false }; return { backgroundColor: "transparent", grid: { containLabel: !horizontal, - left: horizontal ? GRID_EDGE + (nameWidth ?? 0) + NAME_LABEL_MARGIN : GRID_EDGE, - right: horizontal ? VALUE_LABEL_ROOM : GRID_EDGE, + left: horizontal ? GRID_EDGE + nameWidth + NAME_LABEL_MARGIN : GRID_EDGE, + right: horizontal ? GRID_EDGE + valueWidth + VALUE_LABEL_DISTANCE : GRID_EDGE, top: horizontal ? 8 : 24, bottom: 8, }, @@ -322,7 +419,7 @@ export const convertBarGaugeData = (gaugeOptions: any, panelSchema: any) => { xAxis: horizontal ? valueAxis : categoryAxis, yAxis: horizontal ? categoryAxis : valueAxis, series: [ - ...(lcd ? lcdSeries(items, bandsByItem, horizontal, showUnfilled, max) : []), + ...(lcd ? lcdSeries(items, bandsByItem, horizontal, showUnfilled) : []), valueBar, valueLabels, ], diff --git a/web/src/utils/dashboard/convertPanelData.ts b/web/src/utils/dashboard/convertPanelData.ts index 949d16533d..750bc76add 100644 --- a/web/src/utils/dashboard/convertPanelData.ts +++ b/web/src/utils/dashboard/convertPanelData.ts @@ -172,7 +172,11 @@ export const convertPanelData = async ( return { chartType: panelSchema.type, ...gauges, - options: convertBarGaugeData(gauges?.options, panelSchema), + options: convertBarGaugeData( + gauges?.options, + panelSchema, + chartPanelRef?.value?.offsetWidth, + ), }; } catch (error) { if (panelSchema?.queryType == "promql") throw error; diff --git a/web/src/utils/dashboard/convertSQLData.ts b/web/src/utils/dashboard/convertSQLData.ts index f009197738..45dec372f9 100644 --- a/web/src/utils/dashboard/convertSQLData.ts +++ b/web/src/utils/dashboard/convertSQLData.ts @@ -285,6 +285,12 @@ export const convertMultiSQLData = async ( Array.isArray(sparklineData) ? sparklineData[i] : undefined, ), ); + // A bar gauge labels and orders each bar by the query it came from, after the dials are merged. + if (panelSchema.type === "gauge") { + options.at(-1)?.options?.series?.forEach((gauge: any) => { + gauge._queryIndex = panelQueryIndex; + }); + } } const isAnnotationSeries = (series: any) => {