From db1da2f09614bdfd8f3961ff06c9a5dcdfe1234c Mon Sep 17 00:00:00 2001 From: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:54:46 +0530 Subject: [PATCH] fix: MetricCardChart to use PanelSchemaRenderer (#13380) - Replaced ChartRenderer with PanelSchemaRenderer in MetricCardChart.vue for consistent rendering. - Updated MetricCardChart.spec.ts to assert the panel schema and injected data correctly. - Introduced applyMeasuredYAxisLeftInset utility to adjust y-axis label spacing based on content width. - Enhanced convertPromQLData to call applyMeasuredYAxisLeftInset before returning options. - Added tests for applyMeasuredYAxisLeftInset to ensure correct behavior with various configurations. - Implemented compact preview adjustments in heatmap charts to optimize rendering in metrics explorer cards. --- .../components/common/sidebar/FolderList.vue | 2 +- .../dashboards/PanelSchemaRenderer.vue | 15 + .../dashboard/usePanelDataLoader.spec.ts | 50 +++ .../dashboard/usePanelDataLoader.ts | 33 ++ web/src/lib/core/Icon/OIcon.icons.ts | 4 + .../lib/core/ToggleGroup/OToggleGroupItem.vue | 1 - web/src/lib/styles/tokens/dark.css | 6 +- web/src/lib/styles/tokens/semantic.css | 10 +- web/src/plugins/logs/SearchBar.vue | 8 +- .../metrics/explorer/MetricCard.spec.ts | 77 +--- .../plugins/metrics/explorer/MetricCard.vue | 105 +----- .../metrics/explorer/MetricCardChart.spec.ts | 128 +++++-- .../metrics/explorer/MetricCardChart.vue | 329 ++++++------------ .../metrics/explorer/MetricsExplorer.vue | 8 +- .../dashboard/chartDimensionUtils.spec.ts | 98 ++++++ .../utils/dashboard/chartDimensionUtils.ts | 39 +++ .../utils/dashboard/convertPromQLData.spec.ts | 36 ++ web/src/utils/dashboard/convertPromQLData.ts | 7 +- .../promql/convertPromQLHeatmapChart.spec.ts | 77 ++++ .../promql/convertPromQLHeatmapChart.ts | 70 +++- web/src/views/Dashboards/Dashboards.vue | 4 +- 21 files changed, 660 insertions(+), 447 deletions(-) diff --git a/web/src/components/common/sidebar/FolderList.vue b/web/src/components/common/sidebar/FolderList.vue index bdf8983dd0..f3bf3251f8 100644 --- a/web/src/components/common/sidebar/FolderList.vue +++ b/web/src/components/common/sidebar/FolderList.vue @@ -66,7 +66,7 @@ along with this program. If not, see . }} diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index 7cbfa187d6..4d99164628 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -464,6 +464,19 @@ export default defineComponent({ required: false, default: undefined, }, + /** + * Pre-fetched PromQL results to render instead of the panel running its own + * query. `{ data, metadata?, resultMetaData? }`, where `data` is one entry + * per query (the shape the PromQL executor writes to `state.data`). Used by + * the metrics explorer, which owns the fetch lifecycle via its preview + * queue but still wants to render through this component. Undefined for + * normal dashboard panels, which fetch their own data. + */ + injectedPromqlData: { + type: Object, + required: false, + default: undefined, + }, }, emits: [ "updated:data-zoom", @@ -666,6 +679,7 @@ export default defineComponent({ is_ui_histogram, shouldRefreshWithoutCache, regionClusterParams, + injectedPromqlData, } = toRefs(props); // calls the apis to get the data based on the panel config let { @@ -700,6 +714,7 @@ export default defineComponent({ shouldRefreshWithoutCache, regionClusterParams, allowAnnotationsAPI, + injectedPromqlData, ); const { diff --git a/web/src/composables/dashboard/usePanelDataLoader.spec.ts b/web/src/composables/dashboard/usePanelDataLoader.spec.ts index fddacab7d9..fd14b9087d 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.spec.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.spec.ts @@ -502,6 +502,56 @@ describe("usePanelDataLoader", () => { expect(loader.data.value).toEqual([]); }); + it("renders injected PromQL data instead of firing a query", async () => { + const panelSchema = ref({ + id: "test-panel", + queryType: "promql", + queries: [{ query: "up" }], + }); + const selectedTimeObj = ref({ + start_time: new Date(Date.now() - 3600000), + end_time: new Date(), + }); + const variablesData = ref({ values: [] }); + const chartPanelRef = ref({ offsetWidth: 1000 }); + + const injected = ref({ + data: [{ result: [{ values: [[1, "1"]] }] }], + metadata: { queries: [{ startTime: 1000, endTime: 2000 }] }, + resultMetaData: [], + }); + + const loader = usePanelDataLoader( + panelSchema, + selectedTimeObj, + variablesData, + chartPanelRef, + ref(false), // forceLoad + ref("dashboards"), // searchType + ref("d"), // dashboardId + ref("f"), // folderId + ref(null), // reportId + ref(null), // runId + ref(null), // tabId + ref(null), // tabName + ref(null), // searchResponse + ref(false), // is_ui_histogram + ref(null), // dashboardName + ref(null), // folderName + ref(false), // shouldRefreshWithoutCache + ref(undefined), // regionClusterParams + ref(true), // allowAnnotationsAPI + injected, // injectedPromqlData + ); + + await loader.loadData(); + + // The already-fetched results are rendered directly; no executor runs. + expect(loader.loading.value).toBe(false); + expect(loader.data.value).toEqual(injected.value.data); + expect(loader.metadata.value).toEqual(injected.value.metadata); + }); + it("should handle invalid timestamps", () => { const panelSchema = ref({ id: "test-panel", diff --git a/web/src/composables/dashboard/usePanelDataLoader.ts b/web/src/composables/dashboard/usePanelDataLoader.ts index 8a257f350c..c761c5f0d9 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.ts @@ -65,6 +65,7 @@ export const usePanelDataLoader = ( shouldRefreshWithoutCache?: any, regionClusterParams?: any, allowAnnotationsAPI?: any, + injectedPromqlData?: any, ) => { const PANEL_DATA_LOADER_DEBUG = false; const log = (...args: any[]) => { @@ -363,6 +364,26 @@ export const usePanelDataLoader = ( return; } + // Injected-data path: the caller already fetched the PromQL results and + // owns the fetch lifecycle (the metrics explorer's preview queue — + // concurrency-capped, viewport-gated, cancellable, cached). Render those + // results directly instead of firing our own query_range. Skips the + // debounce, cache restore, visibility and variable waits — the caller + // already gated all of that. See MetricCard. + if (injectedPromqlData?.value != null) { + log("loadData: rendering injected PromQL data"); + state.loading = false; + state.isOperationCancelled = false; + state.isPartialData = false; + state.data = markRaw(injectedPromqlData.value.data ?? []); + state.metadata = injectedPromqlData.value.metadata ?? { queries: [] }; + state.resultMetaData = injectedPromqlData.value.resultMetaData ?? []; + state.annotations = []; + state.errorDetail = { message: "", code: "" }; + runCount++; + return; + } + log("loadData: now waiting for the timeout to avoid frequent updates"); await waitForTimeout(abortController.signal); @@ -483,6 +504,18 @@ export const usePanelDataLoader = ( }, ); + // Re-render when the caller hands us a fresh set of injected results (the + // metrics explorer replaces the whole object on every refresh). No-op for + // panels that fetch their own data — the ref stays undefined for them. + if (injectedPromqlData) { + watch( + () => injectedPromqlData.value, + () => { + loadData(); + }, + ); + } + watch( () => [panelSchema?.value], async (newVal, oldVal) => { diff --git a/web/src/lib/core/Icon/OIcon.icons.ts b/web/src/lib/core/Icon/OIcon.icons.ts index c33edb648d..e0730f84d2 100644 --- a/web/src/lib/core/Icon/OIcon.icons.ts +++ b/web/src/lib/core/Icon/OIcon.icons.ts @@ -310,6 +310,8 @@ import Input from "~icons/material-symbols/input"; import Transform from "~icons/material-symbols/transform"; import Favorite from "~icons/material-symbols/favorite"; import FavoriteBorder from "~icons/material-symbols/favorite-outline"; +import Star from "~icons/material-symbols/star"; +import StarOutline from "~icons/material-symbols/star-outline"; import RadioButtonUnchecked from "~icons/material-symbols/radio-button-unchecked"; import RadioButtonChecked from "~icons/material-symbols/radio-button-checked"; import ComputerIcon from "~icons/material-symbols/computer-outline"; @@ -353,6 +355,8 @@ export const iconRegistry = { "stars": Stars, "favorite": Favorite, "favorite-border": FavoriteBorder, + "star": Star, + "star-outline": StarOutline, "backpack": Backpack, "block": Block, "bolt": Bolt, diff --git a/web/src/lib/core/ToggleGroup/OToggleGroupItem.vue b/web/src/lib/core/ToggleGroup/OToggleGroupItem.vue index 83474b96f8..d15d076938 100644 --- a/web/src/lib/core/ToggleGroup/OToggleGroupItem.vue +++ b/web/src/lib/core/ToggleGroup/OToggleGroupItem.vue @@ -28,7 +28,6 @@ const slots = defineSlots(); const context = inject>( TOGGLE_GROUP_CONTEXT_KEY, - undefined, ); /** diff --git a/web/src/lib/styles/tokens/dark.css b/web/src/lib/styles/tokens/dark.css index 6f42f30025..74df451ab4 100644 --- a/web/src/lib/styles/tokens/dark.css +++ b/web/src/lib/styles/tokens/dark.css @@ -537,9 +537,9 @@ --color-status-error-bg: #402929; --color-status-success-text: #A0DBA5; --color-status-success-bg: #253627; - /* Favorite (filled heart) — lighter rose (rose-400) so it reads clearly on - dark surfaces without vibrating the way a saturated rose-500 would. */ - --color-favorite: #FB7185; + /* Favorite (filled star) — lighter yellow (yellow-400) so it reads clearly on + dark surfaces without vibrating the way a saturated yellow-500 would. */ + --color-favorite: #FACC15; } /* ── Domain data-viz: label chips, dark mode ── */ diff --git a/web/src/lib/styles/tokens/semantic.css b/web/src/lib/styles/tokens/semantic.css index 7a208e9724..00954f98cf 100644 --- a/web/src/lib/styles/tokens/semantic.css +++ b/web/src/lib/styles/tokens/semantic.css @@ -70,11 +70,11 @@ so thin small marks stay readable — that dark flip is why it can't be raw primary-600. */ --color-accent: var(--color-primary-600); - /* Favorite (filled heart) — a rose/pink-leaning red, deliberately warmer - than the destructive error red so "loved" and "danger" never read as the - same hue on a row that carries both. Only the FILLED heart uses this; - unfavorited outlines stay neutral. */ - --color-favorite: #F43F5E; + /* Favorite (filled star) — a warm yellow, deliberately distinct from the + destructive error red so "starred" and "danger" never read as the same hue + on a row that carries both. Only the FILLED star uses this; unfavorited + outlines stay neutral. */ + --color-favorite: #EAB308; } /* ── Register ALL semantic tokens for Tailwind utilities ── */ diff --git a/web/src/plugins/logs/SearchBar.vue b/web/src/plugins/logs/SearchBar.vue index 4d18a47c14..3d5aed766d 100644 --- a/web/src/plugins/logs/SearchBar.vue +++ b/web/src/plugins/logs/SearchBar.vue @@ -241,7 +241,7 @@ along with this program. If not, see . . - + { }); /** - * 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 .