-
+
{
});
/**
- * Right-click a data point -> Create Alert, the same gesture a dashboard panel
- * offers. The card hands the alert page the same `panelData` contract
- * usePanelActions builds, so a PromQL alert opens pre-filled.
+ * Create Alert (right-click → Create Alert) now lives in the panel component
+ * itself: the card renders through PanelSchemaRenderer (inside MetricCardChart)
+ * and opts in via `allow-alert-creation`, exactly as a dashboard panel does.
+ * The card's only responsibility is to enable it — the menu, the panelData
+ * contract and the navigation are the panel's (covered by its own tests).
*/
describe("create alert from a right-click on the chart", () => {
const QUERIES = [
@@ -591,63 +593,20 @@ describe("MetricCard (ported to @/lib)", () => {
];
const TIME_RANGE = { start_time: 1_000, end_time: 2_000 };
- beforeEach(() => routerPush.mockClear());
+ it("opts the chart into the panel's Create Alert menu", () => {
+ wrapper = createWrapper({
+ queries: QUERIES,
+ timeRange: TIME_RANGE,
+ preview: preview({
+ results: [{ result: [{ values: [[1, "1"]] }] }],
+ }),
+ });
- const openMenu = (w: VueWrapper, value = 42) => {
- (w.vm as any).onChartContextMenu({ x: 10, y: 20, value });
- return (w.vm as any).alertMenu;
- };
-
- it("opens the menu at the click, seeded with the clicked point's value", () => {
- wrapper = createWrapper({ queries: QUERIES, timeRange: TIME_RANGE });
-
- const menu = openMenu(wrapper, 42);
- // The clicked value seeds the threshold — the alert opens on the number
- // the user actually pointed at.
- expect(menu).toMatchObject({ visible: true, x: 10, y: 20, value: 42 });
- });
-
- it("ignores a right-click that carries no numeric value", () => {
- wrapper = createWrapper({ queries: QUERIES, timeRange: TIME_RANGE });
-
- (wrapper.vm as any).onChartContextMenu({ x: 1, y: 2, value: NaN });
-
- // Off-series right-clicks have no threshold to offer; a menu there would
- // send NaN to the alert page.
- expect((wrapper.vm as any).alertMenu.visible).toBe(false);
- });
-
- it("navigates to addAlert with the card's PromQL as the panelData contract", () => {
- wrapper = createWrapper({ queries: QUERIES, timeRange: TIME_RANGE });
- openMenu(wrapper, 42);
-
- (wrapper.vm as any).onCreateAlert({ condition: ">=", threshold: 42 });
-
- expect(routerPush).toHaveBeenCalledTimes(1);
- const arg = routerPush.mock.calls[0][0];
- expect(arg.name).toBe("addAlert");
- expect(arg.query.fromPanel).toBe("true");
-
- const panelData = JSON.parse(decodeURIComponent(arg.query.panelData));
- // The same contract usePanelActions.handleCreateAlert builds — it is what
- // the alert page reads.
- expect(panelData.queryType).toBe("promql");
- expect(panelData.queries[0].query).toBe(QUERIES[0].expr);
- expect(panelData.executedQuery).toBe(QUERIES[0].expr);
- expect(panelData.threshold).toBe(42);
- expect(panelData.condition).toBe(">=");
- expect(panelData.timeRange).toEqual(TIME_RANGE);
- expect(panelData.panelTitle).toBe(CARD.name);
- // Closing the menu is part of navigating.
- expect((wrapper.vm as any).alertMenu.visible).toBe(false);
- });
-
- it("does not navigate when the card has no query to alert on", () => {
- wrapper = createWrapper({ queries: [], timeRange: TIME_RANGE });
-
- (wrapper.vm as any).onCreateAlert({ condition: ">=", threshold: 1 });
-
- expect(routerPush).not.toHaveBeenCalled();
+ expect(
+ wrapper
+ .findComponent({ name: "MetricCardChart" })
+ .props("allowAlertCreation"),
+ ).toBe(true);
});
});
});
diff --git a/web/src/plugins/metrics/explorer/MetricCard.vue b/web/src/plugins/metrics/explorer/MetricCard.vue
index 755c7fc86a..218cba2697 100644
--- a/web/src/plugins/metrics/explorer/MetricCard.vue
+++ b/web/src/plugins/metrics/explorer/MetricCard.vue
@@ -118,11 +118,12 @@ along with this program. If not, see .
-
+
@@ -464,44 +465,22 @@ along with this program. If not, see .
-
-
diff --git a/web/src/plugins/metrics/explorer/MetricsExplorer.vue b/web/src/plugins/metrics/explorer/MetricsExplorer.vue
index f7624adfe6..896b386eb6 100644
--- a/web/src/plugins/metrics/explorer/MetricsExplorer.vue
+++ b/web/src/plugins/metrics/explorer/MetricsExplorer.vue
@@ -73,11 +73,11 @@ along with this program. If not, see .
size="sm"
data-test="metrics-explorer-mode-workspace"
>
-
-
+
{{ t("metrics.explorer.modeWorkspace") }}
@@ -998,7 +998,7 @@ export default defineComponent({
if (grid.showFavoritesOnly.value) {
actions.push({
id: "clear-favorites",
- icon: "favorite",
+ icon: "star",
titleKey: "metrics.explorer.emptyActions.clearFavorites",
descriptionKey: "metrics.explorer.emptyActions.clearFavoritesDesc",
});
diff --git a/web/src/utils/dashboard/chartDimensionUtils.spec.ts b/web/src/utils/dashboard/chartDimensionUtils.spec.ts
index 5368a67eed..d47a2b7ed9 100644
--- a/web/src/utils/dashboard/chartDimensionUtils.spec.ts
+++ b/web/src/utils/dashboard/chartDimensionUtils.spec.ts
@@ -20,6 +20,7 @@ import {
calculateDynamicNameGap,
calculateNiceTickValues,
calculateRotatedLabelBottomSpace,
+ applyMeasuredYAxisLeftInset,
} from "@/utils/dashboard/chartDimensionUtils";
// calculateWidthText delegates to zrender's text measurement (the same one
@@ -232,4 +233,101 @@ describe("chartDimensionUtils", () => {
expect(positive).toBe(negative);
});
});
+
+ describe("applyMeasuredYAxisLeftInset", () => {
+ const formatter = (v: number) => `${v}c/s`;
+
+ it("widens grid.left to the widest formatted label and disables containLabel", () => {
+ const options = {
+ grid: { left: 10, containLabel: true },
+ yAxis: { type: "value", axisLabel: { formatter } },
+ series: [{ data: [[0, 1], [1, 60000]] }],
+ };
+
+ applyMeasuredYAxisLeftInset(options);
+
+ // "60000c/s" is the widest label; grid.left should equal its measured width
+ expect(options.grid.left).toBe(calculateWidthText("60000c/s"));
+ expect(options.grid.containLabel).toBe(false);
+ });
+
+ it("handles scalar (non-tuple) series points", () => {
+ const options = {
+ grid: { left: 10, containLabel: true },
+ yAxis: { type: "value", axisLabel: { formatter } },
+ series: [{ data: [1, 60000] }],
+ };
+
+ applyMeasuredYAxisLeftInset(options);
+
+ expect(options.grid.left).toBe(calculateWidthText("60000c/s"));
+ expect(options.grid.containLabel).toBe(false);
+ });
+
+ it("no-ops when grid is an array", () => {
+ const grid = [{ left: 10 }];
+ const options = {
+ grid,
+ yAxis: { type: "value", axisLabel: { formatter } },
+ series: [{ data: [[0, 60000]] }],
+ };
+
+ applyMeasuredYAxisLeftInset(options);
+
+ expect(options.grid).toBe(grid);
+ expect(options.grid[0].left).toBe(10);
+ });
+
+ it("no-ops when yAxis is an array", () => {
+ const options = {
+ grid: { left: 10, containLabel: true },
+ yAxis: [{ type: "value", axisLabel: { formatter } }],
+ series: [{ data: [[0, 60000]] }],
+ };
+
+ applyMeasuredYAxisLeftInset(options);
+
+ expect(options.grid.left).toBe(10);
+ expect(options.grid.containLabel).toBe(true);
+ });
+
+ it("no-ops when there is no series data", () => {
+ const options = {
+ grid: { left: 10, containLabel: true },
+ yAxis: { type: "value", axisLabel: { formatter } },
+ series: [{ data: [] }],
+ };
+
+ applyMeasuredYAxisLeftInset(options);
+
+ expect(options.grid.left).toBe(10);
+ expect(options.grid.containLabel).toBe(true);
+ });
+
+ it("no-ops for a non-value axis", () => {
+ const options = {
+ grid: { left: 10, containLabel: true },
+ yAxis: { type: "category", axisLabel: { formatter } },
+ series: [{ data: [[0, 60000]] }],
+ };
+
+ applyMeasuredYAxisLeftInset(options);
+
+ expect(options.grid.left).toBe(10);
+ expect(options.grid.containLabel).toBe(true);
+ });
+
+ it("no-ops when there is no formatter function", () => {
+ const options = {
+ grid: { left: 10, containLabel: true },
+ yAxis: { type: "value", axisLabel: {} },
+ series: [{ data: [[0, 60000]] }],
+ };
+
+ applyMeasuredYAxisLeftInset(options);
+
+ expect(options.grid.left).toBe(10);
+ expect(options.grid.containLabel).toBe(true);
+ });
+ });
});
diff --git a/web/src/utils/dashboard/chartDimensionUtils.ts b/web/src/utils/dashboard/chartDimensionUtils.ts
index 5b19248a19..afeaddc4c0 100644
--- a/web/src/utils/dashboard/chartDimensionUtils.ts
+++ b/web/src/utils/dashboard/chartDimensionUtils.ts
@@ -119,6 +119,45 @@ export const calculateOptimalFontSize = (
return optimalFontSize; // Return the largest font size that fits
};
+/**
+ * Widens the ECharts grid's left inset to the measured pixel width of the
+ * widest formatted y-axis label. ECharts' containLabel under-measures rendered
+ * label width, so wide labels clip at the left edge; we measure with the same
+ * canvas API ECharts renders with and reserve exactly that much, disabling
+ * containLabel so the two reservations don't stack.
+ *
+ * @param options - the ECharts options object (mutated in place).
+ */
+export const applyMeasuredYAxisLeftInset = (options: any): void => {
+ const grid = options?.grid;
+ const yAxis = options?.yAxis;
+
+ // Arrays are gauge/trellis layouts that manage their own left spacing.
+ const isPlainObject = (v: any) =>
+ v && typeof v === "object" && !Array.isArray(v);
+ if (!isPlainObject(grid) || !isPlainObject(yAxis)) return;
+ if (yAxis.type !== "value") return;
+
+ const formatter = yAxis.axisLabel?.formatter;
+ if (typeof formatter !== "function") return;
+
+ let widest = 0;
+ const series = Array.isArray(options?.series) ? options.series : [];
+ for (const s of series) {
+ const data = Array.isArray(s?.data) ? s.data : [];
+ for (const point of data) {
+ const y = Array.isArray(point) ? point[1] : point;
+ const width = calculateWidthText(String(formatter(y)));
+ if (width > widest) widest = width;
+ }
+ }
+
+ if (widest > 0) {
+ options.grid.left = widest;
+ options.grid.containLabel = false;
+ }
+};
+
/**
* Calculates a dynamic nameGap for the x-axis based on label rotation.
* When labels are rotated, we need more space between the labels and the axis name.
diff --git a/web/src/utils/dashboard/convertPromQLData.spec.ts b/web/src/utils/dashboard/convertPromQLData.spec.ts
index a541472def..3128df22b9 100644
--- a/web/src/utils/dashboard/convertPromQLData.spec.ts
+++ b/web/src/utils/dashboard/convertPromQLData.spec.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { convertPromQLData } from "./convertPromQLData";
import { getPropsByChartTypeForSeries } from "./promqlChartSeriesProps";
import { chartColor } from "@/utils/chartTheme";
+import { applyMeasuredYAxisLeftInset } from "./chartDimensionUtils";
// Mock dependencies
vi.mock("./convertDataIntoUnitValue", () => ({
@@ -23,6 +24,7 @@ vi.mock("./chartDimensionUtils", () => ({
calculateWidthText: vi.fn((text) => (text?.length || 0) * 8),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
+ applyMeasuredYAxisLeftInset: vi.fn(),
}));
vi.mock("./chartColorUtils", () => ({
@@ -188,6 +190,40 @@ describe("Convert PromQL Data Utils", () => {
expect(result).toEqual({ options: null });
});
+ it("should call applyMeasuredYAxisLeftInset with the final options before returning", async () => {
+ const panelSchema = {
+ id: "panel1",
+ type: "line",
+ config: { show_legends: true, legends_position: "bottom" },
+ queries: [{ config: { promql_legend: "{{job}}" } }],
+ };
+ const searchQueryData = [
+ {
+ resultType: "matrix",
+ result: [
+ {
+ metric: { job: "test-job" },
+ values: [
+ [1640435200, "10"],
+ [1640435260, "20"],
+ ],
+ },
+ ],
+ },
+ ];
+
+ const result = await convertPromQLData(
+ panelSchema,
+ searchQueryData,
+ mockStore,
+ mockChartPanelRef,
+ mockHoveredSeriesState,
+ mockAnnotations,
+ );
+
+ expect(applyMeasuredYAxisLeftInset).toHaveBeenCalledWith(result.options);
+ });
+
it("should process valid line chart data", async () => {
const panelSchema = {
id: "panel1",
diff --git a/web/src/utils/dashboard/convertPromQLData.ts b/web/src/utils/dashboard/convertPromQLData.ts
index 580943fa7d..865257c74b 100644
--- a/web/src/utils/dashboard/convertPromQLData.ts
+++ b/web/src/utils/dashboard/convertPromQLData.ts
@@ -41,6 +41,7 @@ import { convertPromQLChartData } from "./promql/convertPromQLChartData";
import { calculateMetricFontSize } from "./sql/charts/convertSQLMetricChart";
import { getPromqlLegendName, getLegendPosition } from "./promql/shared/legendBuilder";
import { getPropsByChartTypeForSeries } from "./promqlChartSeriesProps";
+import { applyMeasuredYAxisLeftInset } from "./chartDimensionUtils";
let moment: any;
let momentInitialized = false;
@@ -586,7 +587,9 @@ export const convertPromQLData = async (
// can auto-range with its natural padding (avoids bar clipping at
// the y-axis edge where gap-filled data can extend beyond the query
// start).
- if (loading && metadata?.queries?.[0]?.startTime && metadata?.queries?.[0]?.endTime) {
+ const pinXAxis =
+ loading || panelSchema?.config?.pin_x_axis_to_range === true;
+ if (pinXAxis && metadata?.queries?.[0]?.startTime && metadata?.queries?.[0]?.endTime) {
const queryStartMs = metadata.queries[0].startTime / 1000; // µs to ms
const queryEndMs = metadata.queries[0].endTime / 1000;
@@ -1274,6 +1277,8 @@ export const convertPromQLData = async (
// promql query will be always timeseries except gauge and metric text chart.
// console.timeEnd("convertPromQLData");
+ // Reserve measured left space so wide y-axis labels don't clip.
+ applyMeasuredYAxisLeftInset(options);
return {
options,
extras: {
diff --git a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts
index 61449e9b55..dbf26ee928 100644
--- a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts
+++ b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts
@@ -1659,3 +1659,80 @@ describe("bucket labels must be unique — ECharts category axes require it", ()
expect(new Set(labels).size).toBe(labels.length); // ...all distinct
});
});
+
+describe("compact_preview shrinks the heatmap for the metrics explorer cards", () => {
+ const converter = new HeatmapConverter();
+ const store = { state: { timezone: "UTC", theme: "light" } };
+
+ // A small classic histogram: two buckets over one timestamp.
+ const processedData: ProcessedPromQLData[] = [
+ {
+ series: [
+ { name: "a", metric: { le: "0.5" }, values: [], data: { "1000": "3" } },
+ { name: "b", metric: { le: "+Inf" }, values: [], data: { "1000": "7" } },
+ ],
+ timestamps: [[1000, "t1"]],
+ queryIndex: 0,
+ },
+ ];
+
+ const convertWith = (extraConfig: Record) =>
+ converter.convert(
+ processedData,
+ {
+ type: "heatmap",
+ config: { heatmap_mode: "prometheus_histogram", ...extraConfig },
+ },
+ store,
+ { legends: [] },
+ ) as any;
+
+ it("leaves the full-size look untouched when the flag is absent", () => {
+ const result = convertWith({});
+ // Default ECharts colour bar — no card sizing applied.
+ expect(result.visualMap.itemWidth).toBeUndefined();
+ expect(result.grid.top).toBeUndefined();
+ expect(result.yAxis.axisLabel.fontSize).toBeUndefined();
+ });
+
+ it("shrinks the colour bar and pins it under the axis", () => {
+ const result = convertWith({ compact_preview: true });
+ expect(result.visualMap).toMatchObject({
+ orient: "horizontal",
+ bottom: 0,
+ itemWidth: 10,
+ itemHeight: 90,
+ textStyle: { fontSize: 9 },
+ });
+ // Room for the bar; no top gap on a short card.
+ expect(result.grid.top).toBe(8);
+ expect(result.grid.bottom).toBe(26);
+ });
+
+ it("thins the bucket labels but always keeps the top +Inf row", () => {
+ const result = convertWith({ compact_preview: true });
+ const axisLabel = result.yAxis.axisLabel;
+ expect(axisLabel.fontSize).toBe(9);
+ // The interval predicate is indexed from the bottom; the TOP row (highest
+ // index = +Inf) must always be labelled.
+ const rowCount = result.yAxis.data.length;
+ expect(axisLabel.interval(rowCount - 1)).toBe(true);
+ });
+
+ it("shrinks the x-axis time labels", () => {
+ const result = convertWith({ compact_preview: true });
+ expect(result.xAxis[0].axisLabel).toMatchObject({
+ fontSize: 9,
+ hideOverlap: true,
+ });
+ });
+
+ it("portals the tooltip regardless of the compact flag (not a card-only concern)", () => {
+ // Same treatment as the other PromQL charts — a heatmap on a dashboard with
+ // overflow-hidden clips its tooltip too, so this is not gated on the card.
+ expect(convertWith({}).tooltip.appendToBody).toBe(true);
+ expect(convertWith({ compact_preview: true }).tooltip.appendToBody).toBe(
+ true,
+ );
+ });
+});
diff --git a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts
index b966b17441..8862e7d108 100644
--- a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts
+++ b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts
@@ -160,6 +160,62 @@ function formatBucketLabel(le: string, leValue: number, config: any): string {
}
}
+/**
+ * Card-sized heatmap. The metrics explorer's preview cards are a few cm tall,
+ * where the default colour bar and full-size axis labels swamp the plot (the
+ * cells collapse to a thin line and only one bucket label survives). Shrinks the
+ * visual map, thins the bucket labels — always keeping the top `+Inf` row — and
+ * drops the font sizes. Mutates and returns `options`. Only run when the card
+ * sets `config.compact_preview`.
+ */
+function applyCompactPreview(options: any): any {
+ if (options.visualMap) {
+ options.visualMap = {
+ ...options.visualMap,
+ show: true,
+ orient: "horizontal",
+ left: "center",
+ bottom: 0,
+ itemWidth: 10,
+ itemHeight: 90,
+ precision: 2,
+ textStyle: { fontSize: 9 },
+ };
+ // Pull the plot to the top edge (no default top inset on a short card) and
+ // leave room for the bar below the x-axis labels.
+ options.grid = { ...(options.grid ?? {}), top: 8, bottom: 26 };
+ }
+
+ const yAxis = Array.isArray(options.yAxis) ? options.yAxis[0] : options.yAxis;
+ const rowCount = yAxis?.data?.length ?? 0;
+ if (yAxis && rowCount) {
+ // Thin the bucket labels to what a card can render, but ALWAYS keep the top
+ // row: a histogram's `+Inf` bucket is the "everything slower than this" row,
+ // and ECharts' hideOverlap would otherwise drop it. Count DOWN from the top
+ // so `+Inf` is always labelled and the spacing stays even.
+ const step = Math.max(1, Math.ceil(rowCount / 8));
+ yAxis.axisLabel = {
+ ...(yAxis.axisLabel ?? {}),
+ fontSize: 9,
+ width: 46,
+ overflow: "truncate",
+ hideOverlap: false,
+ interval: (index: number) => (rowCount - 1 - index) % step === 0,
+ };
+ }
+
+ const xAxis = Array.isArray(options.xAxis) ? options.xAxis[0] : options.xAxis;
+ if (xAxis) {
+ xAxis.axisLabel = {
+ ...(xAxis.axisLabel ?? {}),
+ fontSize: 9,
+ hideOverlap: true,
+ };
+ }
+
+ return options;
+}
+
/**
* Converter for heatmap charts
* Displays time-series data as a 2D heatmap with color intensity
@@ -228,7 +284,7 @@ export class HeatmapConverter implements PromQLChartConverter {
// Format timestamps to extract time portion (consistent with bar charts)
const xAxisData = buildXAxisData(timeAxis);
- return {
+ const options: any = {
series: [
{
type: "heatmap",
@@ -292,6 +348,9 @@ export class HeatmapConverter implements PromQLChartConverter {
},
tooltip: {
position: "top",
+ // render into so the tooltip is not clipped by the panel's
+ // overflow — same container treatment as the other PromQL charts
+ appendToBody: true,
textStyle: {
color: chartColor("--color-tooltip-text"),
fontSize: 12,
@@ -325,6 +384,8 @@ export class HeatmapConverter implements PromQLChartConverter {
containLabel: true,
},
};
+
+ return config?.compact_preview ? applyCompactPreview(options) : options;
}
/**
@@ -402,7 +463,7 @@ export class HeatmapConverter implements PromQLChartConverter {
const xAxisData = buildXAxisData(timestamps);
- return {
+ const options: any = {
series: [
{
type: "heatmap",
@@ -450,6 +511,9 @@ export class HeatmapConverter implements PromQLChartConverter {
},
tooltip: {
position: "top",
+ // render into so the tooltip is not clipped by the panel's
+ // overflow — same container treatment as the other PromQL charts
+ appendToBody: true,
textStyle: {
color: chartColor("--color-tooltip-text"),
fontSize: 12,
@@ -484,5 +548,7 @@ export class HeatmapConverter implements PromQLChartConverter {
containLabel: true,
},
};
+
+ return config?.compact_preview ? applyCompactPreview(options) : options;
}
}
diff --git a/web/src/views/Dashboards/Dashboards.vue b/web/src/views/Dashboards/Dashboards.vue
index d31383c2ff..3a8f78ede2 100644
--- a/web/src/views/Dashboards/Dashboards.vue
+++ b/web/src/views/Dashboards/Dashboards.vue
@@ -209,13 +209,13 @@ along with this program. If not, see .
-