mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user