fix(dashboards): draw each bar gauge bar on its own range and keep labels readable

- Each bar fills as a fraction of its own query's min..max on a 0..1 axis,
  so a query's Fixed range is honoured next to another query's range and
  negative values fill from the start of the track. LCD cells and gradient
  stops map the same way.
- The horizontal value gutter is sized to the widest value text (capped,
  with an ellipsis), and both gutters are capped by a share of the panel.
- Vertical names and values truncate to the width each bar gets; when a
  slot is too narrow for an ellipsis, labels thin out and values hide.
- An empty group is labelled "(empty)" instead of the value column's name;
  the value-field fallback still applies without a label field or rows.
- Bars from a query with no top-level ORDER BY (or Builder sort) are kept
  in name order, per query, so a refreshing panel does not reshuffle.

Refs #16228
This commit is contained in:
ktx-vaidehi
2026-11-03 18:30:04 +05:30
parent fa128f2575
commit bc9ea480e8
4 changed files with 498 additions and 49 deletions
@@ -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<string, any>) => {
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],
+131 -34
View File
@@ -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<string, unknown> => {
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,
],
+5 -1
View File
@@ -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;
@@ -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) => {