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.
This commit is contained in:
Abhay Padamani
2026-07-22 14:24:46 +00:00
committed by GitHub
parent 7a88daa8d1
commit db1da2f096
21 changed files with 660 additions and 447 deletions
@@ -66,7 +66,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
}}</span>
<OIcon
v-if="tab.folderId === FAVORITES_FOLDER_ID"
name="favorite"
name="star"
size="sm"
class="shrink-0 text-favorite"
/>
@@ -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 {
@@ -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",
@@ -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) => {
+4
View File
@@ -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,
@@ -28,7 +28,6 @@ const slots = defineSlots<ToggleGroupItemSlots>();
const context = inject<ComputedRef<ToggleGroupContext>>(
TOGGLE_GROUP_CONTEXT_KEY,
undefined,
);
/**
+3 -3
View File
@@ -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 ── */
+5 -5
View File
@@ -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 ── */
+4 -4
View File
@@ -241,7 +241,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon
:name="
favoriteViews.includes(view.view_id)
? 'favorite'
? 'star'
: 'saved-search'
"
size="sm"
@@ -1792,8 +1792,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon
:name="
favoriteViews.includes(row.view_id)
? 'favorite'
: 'favorite-border'
? 'star'
: 'star-outline'
"
size="xs"
:class="
@@ -1883,7 +1883,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`logs-search-bar-favorite-${row.view_id}-saved-view-btn`"
@click.stop="handleFavoriteSavedView(row, true)"
>
<OIcon name="favorite" size="xs" class="text-favorite" />
<OIcon name="star" size="xs" class="text-favorite" />
</OButton>
<OButton
:title="t('common.edit')"
@@ -581,9 +581,11 @@ describe("MetricCard (ported to @/lib)", () => {
});
/**
* 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<any>, 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);
});
});
});
+4 -101
View File
@@ -118,11 +118,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTooltip :content="t('metrics.explorer.card.openTooltip')" />
</OButton>
<!-- Pin (star). Always visible. -->
<!-- Pin (star). Always visible. Filled gold star when favorited. -->
<OButton
variant="ghost"
size="icon"
:icon-left="isFavorite ? 'favorite' : 'favorite-border'"
:icon-left="isFavorite ? 'star' : 'star-outline'"
:class="isFavorite ? 'text-favorite' : ''"
:aria-label="
isFavorite
? t('metrics.explorer.card.favoriteRemoveAria', {
@@ -373,9 +374,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:bucket-unit-custom="bucketO2Unit.unitCustom ?? undefined"
:color="color"
:time-range="dataTimeRange"
:allow-alert-creation="true"
@error="renderError = String($event ?? '')"
@zoom="$emit('zoom', $event)"
@contextmenu="onChartContextMenu"
/>
<div v-else class="h-full">
@@ -464,44 +465,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<!-- Create Alert — the SAME menu the dashboard panel raises on right-click
(PanelSchemaRenderer mounts this exact component), so the gesture and the
wording are identical wherever a chart is. Position is viewport-fixed
from the click, so the card's `overflow-hidden` cannot clip it. -->
<AlertContextMenu
:visible="alertMenu.visible"
:x="alertMenu.x"
:y="alertMenu.y"
:value="alertMenu.value"
@select="onCreateAlert"
@close="alertMenu.visible = false"
/>
</div>
</template>
<script lang="ts">
import {
computed,
defineAsyncComponent,
defineComponent,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
type PropType,
} from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import useTheme from "@/composables/useTheme";
import MetricCardChart from "./MetricCardChart.vue";
// Async, exactly as PanelSchemaRenderer imports it: the menu is only ever
// needed once a user right-clicks a chart, so it stays out of the grid's chunk.
const AlertContextMenu = defineAsyncComponent(
() => import("@/components/dashboards/AlertContextMenu.vue"),
);
import RelativeTime from "@/components/common/RelativeTime.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
@@ -556,7 +535,6 @@ export default defineComponent({
LoadingProgress,
OTag,
OTooltip,
AlertContextMenu,
},
props: {
card: { type: Object as PropType<MetricCardModel>, required: true },
@@ -594,81 +572,9 @@ export default defineComponent({
],
setup(props, { emit }) {
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const root = ref<HTMLElement | null>(null);
const { isDark } = useTheme();
/* ------------------------------------------------ create alert */
/**
* Right-click a data point -> Create Alert, the same gesture a dashboard
* panel offers. Handled HERE rather than emitted to the explorer: the menu is
* positioned from the click and the payload is this card's own query, so
* there is nothing for the parent to decide. (`contextmenu` is also a real
* DOM event name — emitting it would bind to the root element and fire on
* every right-click anywhere on the card, which is the trap the `refresh`
* note above describes.)
*/
const alertMenu = reactive({ visible: false, x: 0, y: 0, value: 0 });
const onChartContextMenu = (event: {
x: number;
y: number;
value: number;
}) => {
// The clicked point's value seeds the threshold, so the alert opens
// pre-filled with the number the user actually pointed at.
if (!Number.isFinite(event?.value)) return;
alertMenu.visible = true;
alertMenu.x = event.x;
alertMenu.y = event.y;
alertMenu.value = event.value;
};
/**
* The same `panelData` contract the dashboard path builds
* (usePanelActions.handleCreateAlert) and the alert page reads: queries +
* queryType + timeRange + the chosen condition/threshold.
*
* `yAxisColumn` is deliberately absent — it is SQL-only there too; a PromQL
* alert thresholds the expression's own value.
*/
const onCreateAlert = (selection: {
condition: string;
threshold: number;
}) => {
alertMenu.visible = false;
const queries = props.queries ?? [];
if (!queries.length) return;
const panelData = {
panelTitle: props.card.name,
panelId: `metrics-explorer-card-${props.card.name}`,
queries: queries.map((q: any) => ({
query: q.expr,
customQuery: true,
fields: { stream_type: "metrics" },
config: { promql_legend: q.legendTemplate ?? "" },
})),
queryType: "promql",
timeRange: props.timeRange,
threshold: selection.threshold,
condition: selection.condition,
executedQuery: queries[0]?.expr,
};
router.push({
name: "addAlert",
query: {
org_identifier: store.state.selectedOrganization?.identifier,
fromPanel: "true",
panelData: encodeURIComponent(JSON.stringify(panelData)),
},
});
};
const color = computed(() => cardColorForIndex(props.index, isDark.value));
// Kept for the card's aria label; the VISIBLE badge renders through the
// registry's metricType group, which owns the label and colour.
@@ -858,9 +764,6 @@ export default defineComponent({
dataTimeRange,
renderError,
onRetryRender,
alertMenu,
onChartContextMenu,
onCreateAlert,
};
},
});
@@ -13,28 +13,34 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect } from "vitest";
import { mount, config } from "@vue/test-utils";
import { nextTick } from "vue";
import i18n from "@/locales";
config.global.plugins = [...(config.global.plugins ?? []), i18n];
// Hoisted: a vi.mock factory runs before module-level consts exist.
const { convertPromQLData } = vi.hoisted(() => ({
convertPromQLData: vi.fn(async () => ({ options: { series: [{}] } })),
}));
vi.mock("@/utils/dashboard/convertPromQLData", () => ({ convertPromQLData }));
vi.mock("vuex", () => ({ useStore: () => ({ state: { theme: "light" } }) }));
vi.mock("@/components/dashboards/panelSchemaRenderer/ChartRenderer.vue", () => ({
default: { name: "ChartRenderer", template: "<div data-test='chart-renderer' />" },
}));
import MetricCardChart from "./MetricCardChart.vue";
const RESULTS = [{ resultType: "matrix", result: [{ metric: {}, values: [[1, "1"]] }] }];
// Records the props the card hands to the panel. The card is now a thin adapter
// over PanelSchemaRenderer — its job is to translate the queue's fetched results
// into a panel schema + injected data — so that translation is what we assert.
const PanelSchemaRendererStub = {
name: "PanelSchemaRenderer",
props: [
"panelSchema",
"selectedTimeObj",
"variablesData",
"injectedPromqlData",
"allowAlertCreation",
"allowAnnotationsAdd",
"allowAnnotationsAPI",
],
template: "<div data-test='panel-schema-renderer' />",
};
const mountChart = (props: Record<string, any> = {}) =>
mount(MetricCardChart, {
props: {
@@ -46,62 +52,110 @@ const mountChart = (props: Record<string, any> = {}) =>
bucketUnit: null,
bucketUnitCustom: null,
color: "#60a5fa",
timeRange: { start_time: 1_000_000, end_time: 2_000_000 },
...props,
},
global: { stubs: { ChartRenderer: true } },
global: { stubs: { PanelSchemaRenderer: PanelSchemaRendererStub } },
});
describe("MetricCardChart re-renders when anything it DRAWS WITH changes", () => {
beforeEach(() => convertPromQLData.mockClear());
const panelProp = (wrapper: any, name: string) =>
wrapper.findComponent(PanelSchemaRendererStub).props(name);
describe("MetricCardChart builds the panel schema from its props", () => {
it("renders through PanelSchemaRenderer, not a bespoke chart", () => {
const wrapper = mountChart();
expect(wrapper.findComponent(PanelSchemaRendererStub).exists()).toBe(true);
});
/**
* The watch used to track only [results, chartType, color, unit]. Two variants can
* share a chart type AND a canonical unit — `unit` is "custom" for both — and
* differ only in `unit_custom` ("c/s" vs "s/s"). The chart then kept formatting
* its cells with the SUPERSEDED unit until something unrelated forced a redraw.
* Two variants can share a chart type AND a canonical unit — `unit` is
* "custom" for both — and differ only in `unit_custom` ("c/s" vs "s/s"). The
* schema must track the live value so the axis is not formatted with a
* superseded unit.
*/
it("re-renders when unitCustom changes but unit does not", async () => {
it("reflects unitCustom in the schema config and updates when it changes", async () => {
const wrapper = mountChart();
await nextTick();
const before = convertPromQLData.mock.calls.length;
expect(panelProp(wrapper, "panelSchema").config.unit_custom).toBe("c/s");
await wrapper.setProps({ unitCustom: "s/s" }); // `unit` stays "custom"
await nextTick();
expect(convertPromQLData.mock.calls.length).toBeGreaterThan(before);
const schema = convertPromQLData.mock.calls.at(-1)![0] as any;
expect(schema.config.unit_custom).toBe("s/s");
expect(panelProp(wrapper, "panelSchema").config.unit_custom).toBe("s/s");
});
it("re-renders when the histogram bucket unit changes", async () => {
it("carries the histogram bucket unit for a heatmap and updates it", async () => {
const wrapper = mountChart({
chartType: "heatmap",
bucketUnit: "seconds",
bucketUnitCustom: null,
});
await nextTick();
const before = convertPromQLData.mock.calls.length;
expect(panelProp(wrapper, "panelSchema").config.bucket_unit).toBe("seconds");
await wrapper.setProps({ bucketUnit: "milliseconds" });
await nextTick();
expect(convertPromQLData.mock.calls.length).toBeGreaterThan(before);
const schema = convertPromQLData.mock.calls.at(-1)![0] as any;
expect(schema.config.bucket_unit).toBe("milliseconds");
expect(panelProp(wrapper, "panelSchema").config.bucket_unit).toBe(
"milliseconds",
);
});
it("re-renders when the queries change", async () => {
it("puts the queries into the schema and updates them", async () => {
const wrapper = mountChart();
await nextTick();
const before = convertPromQLData.mock.calls.length;
expect(panelProp(wrapper, "panelSchema").queries[0].query).toBe("up");
await wrapper.setProps({
queries: [{ expr: "sum(rate(up[5m]))", legendTemplate: "{le}" }],
});
await nextTick();
expect(convertPromQLData.mock.calls.length).toBeGreaterThan(before);
const schema = convertPromQLData.mock.calls.at(-1)![0] as any;
expect(schema.queries[0].query).toBe("sum(rate(up[5m]))");
expect(panelProp(wrapper, "panelSchema").queries[0].query).toBe(
"sum(rate(up[5m]))",
);
});
it("pins the x-axis to the queried range (injected, non-streaming data)", () => {
const wrapper = mountChart();
expect(panelProp(wrapper, "panelSchema").config.pin_x_axis_to_range).toBe(
true,
);
});
it("connects across null gaps so a sparse line is not fragmented", () => {
const wrapper = mountChart();
expect(panelProp(wrapper, "panelSchema").config.connect_nulls).toBe(true);
});
});
describe("MetricCardChart feeds the queue's results in as injected data", () => {
it("hands the results and the queried window (µs) to the panel", () => {
const wrapper = mountChart();
const injected = panelProp(wrapper, "injectedPromqlData");
expect(injected.data).toEqual(RESULTS);
expect(injected.metadata.queries[0]).toEqual({
startTime: 1_000_000,
endTime: 2_000_000,
});
});
it("converts the µs window into the Date pair the panel expects", () => {
const wrapper = mountChart();
const time = panelProp(wrapper, "selectedTimeObj");
// 1_000_000 µs = 1000 ms, 2_000_000 µs = 2000 ms.
expect((time.start_time as Date).getTime()).toBe(1000);
expect((time.end_time as Date).getTime()).toBe(2000);
});
});
describe("MetricCardChart forwards alert-creation opt-in", () => {
it("defaults alert creation off (function-preview tiles)", () => {
const wrapper = mountChart();
expect(panelProp(wrapper, "allowAlertCreation")).toBe(false);
});
it("passes allowAlertCreation through to the panel when set", () => {
const wrapper = mountChart({ allowAlertCreation: true });
expect(panelProp(wrapper, "allowAlertCreation")).toBe(true);
});
});
@@ -17,44 +17,37 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!--
A card's preview chart.
Runs the preview response through the same PromQL -> ECharts converter the
dashboards use, so a card renders exactly what the drilled-in panel will. The
card-specific styling (fixed series colour by card index, 9% area fill, no
legend) is applied to the converted options rather than forked into a second
renderer.
Renders the preview response through the SAME component a dashboard panel uses
(PanelSchemaRenderer), so a card draws exactly what the drilled-in panel will.
The results are already fetched — the metrics explorer's preview queue owns the
fetch lifecycle (concurrency-capped, viewport-gated, cancellable, cached) — so
they are handed to the panel via `injectedPromqlData` instead of letting it
fire its own query. The card-specific look (fixed colour, no legend, pinned
axis) is expressed as panel config the shared converter already understands.
-->
<template>
<div ref="chartPanelRef" class="w-full h-full">
<ChartRenderer
v-if="chartData.options"
:data="chartData"
:height="height"
@error="onChartError"
<div ref="chartPanelRef" class="w-full h-full" :style="{ height }">
<PanelSchemaRenderer
:panel-schema="panelSchema"
:selected-time-obj="selectedTimeObj"
:variables-data="variablesData"
:injected-promql-data="injectedPromqlData"
:allow-alert-creation="allowAlertCreation"
:allow-annotations-add="false"
:allow-annotations-a-p-i="false"
@error="onPanelError"
@updated:data-zoom="$emit('zoom', $event)"
@domcontextmenu="$emit('contextmenu', $event)"
/>
</div>
</template>
<script lang="ts">
import {
defineComponent,
onBeforeUnmount,
onMounted,
ref,
watch,
type PropType,
} from "vue";
import { useStore } from "vuex";
import { useI18n } from "vue-i18n";
import * as echarts from "echarts/core";
import { toZonedTime } from "date-fns-tz";
import ChartRenderer from "@/components/dashboards/panels/ChartRenderer.vue";
import { convertPromQLData } from "@/utils/dashboard/convertPromQLData";
import { computed, defineComponent, type PropType } from "vue";
import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue";
export default defineComponent({
name: "MetricCardChart",
components: { ChartRenderer },
components: { PanelSchemaRenderer },
props: {
/** One PromQL query_range response per query in the effective variant. */
results: { type: Array as PropType<any[]>, required: true },
@@ -68,33 +61,30 @@ export default defineComponent({
height: { type: String, default: "100%" },
/**
* The window these results were queried for, in MICROSECONDS — the same unit
* the grid holds it in. Without it the time axis is whatever ECharts infers
* from the data, which is not the window the user picked. See `pinTimeAxis`.
* the grid holds it in. Handed to the panel as the time range AND (via the
* `pin_x_axis_to_range` config) the range the x-axis is pinned to, so a
* sparsely-scraped metric with a single point does not auto-range into a
* ~two-day axis.
*/
timeRange: {
type: Object as PropType<{ start_time: number; end_time: number }>,
default: null,
},
/**
* Opt in to the panel's right-click → Create Alert menu. On for a real
* explorer card; off for a function-config preview tile, which is not a
* place to author an alert.
*/
allowAlertCreation: { type: Boolean, default: false },
},
/**
* `zoom` carries ECharts' `{start, end}` (epoch ms) from a drag-select on the
* chart.
*
* Not emitted for the heatmap: its x-axis is categorical (one column per
* bucket), so the converter withholds the toolbox there anyway.
*
* `contextmenu` carries ChartRenderer's `{x, y, value}` from a right-click on a
* data point — the same event PanelSchemaRenderer turns into Create Alert.
* ChartRenderer only fires it for line/bar series, so a heatmap card is
* naturally excluded (a bucket count is not an alert threshold).
* `zoom` carries the panel's `updated:data-zoom` payload (a drag-select on the
* chart). `error` carries a conversion/render failure message so the card can
* show its error tile — the query itself already succeeded (these are already
* fetched results), so this is a render-time failure only.
*/
emits: ["error", "zoom", "contextmenu"],
emits: ["error", "zoom"],
setup(props, { emit }) {
const store = useStore();
const { t } = useI18n();
const chartPanelRef = ref(null);
const chartData = ref<any>({ options: null });
/**
* Decimal places that keep the axis readable at the data's magnitude.
*
@@ -122,14 +112,16 @@ export default defineComponent({
};
/**
* A minimal panel schema. It is the same shape the drill-in hands to the
* editor, which is what keeps "what you click is what you get" true.
* The panel schema. The same shape the drill-in hands the editor, which is
* what keeps "what you click is what you get" true. `type` is fixed from the
* card's resolved variant (metadata-driven, never inferred from the data).
*/
const buildPanelSchema = () => ({
const panelSchema = computed(() => ({
id: "metrics-explorer-card",
title: "",
type: props.chartType,
queryType: "promql",
queries: props.queries.map((q: any) => ({
queries: (props.queries ?? []).map((q: any) => ({
query: q.expr,
customQuery: true,
fields: { stream_type: "metrics" },
@@ -141,205 +133,88 @@ export default defineComponent({
decimals: adaptiveDecimals(),
show_legends: false,
// Gridlines make a small chart readable — without them a sparkline is
// just a shape. The heatmap is solid colour, so they'd only add noise
// there; it opts out below.
// just a shape. The heatmap is solid colour, so they'd only add noise.
show_gridlines: props.chartType !== "heatmap",
show_symbol: false,
connect_nulls: true,
line_interpolation: "smooth",
line_thickness: 1.5,
color: { mode: "fixed", fixedColor: [props.color] },
// Activates the classic-histogram transform (le-sort + de-accumulate).
// Without it a cumulative-bucket heatmap renders as nonsense.
// Injected data is never "loading", so the converter would auto-range
// the x-axis; pin it to the queried window instead. See `timeRange`.
pin_x_axis_to_range: true,
// Activates the classic-histogram transform (le-sort + de-accumulate)
// and the card-sized heatmap look (small colour bar, thinned bucket
// labels, no top gap). Without them a cumulative-bucket heatmap renders
// as nonsense on a full-size layout that swamps a card.
...(props.chartType === "heatmap"
? {
heatmap_mode: "prometheus_histogram",
compact_preview: true,
bucket_unit: props.bucketUnit,
bucket_unit_custom: props.bucketUnitCustom,
}
: {}),
},
});
}));
/**
* Pins the time axis to the window the data was QUERIED for.
*
* `convertPromQLData` sets `xAxis.min`/`max` only while a panel is streaming;
* a settled chart is left to ECharts, which scales the axis to the data it was
* given. A card with very few points (a sparsely-scraped metric can return
* ONE) makes ECharts invent a span of its own — widened out to about two days
* — so the axis disagrees with the picker. State the known window instead.
* This also keeps every card spanning the same window, so a spike at the same
* x is the same moment.
*
* Not applied to the heatmap: its x-axis is categorical (one column per
* bucket), so a time min/max means nothing there.
*/
const pinTimeAxis = (options: any) => {
/** The queried window as the Date pair PanelSchemaRenderer expects. */
const selectedTimeObj = computed(() => {
const range = props.timeRange;
if (!range?.start_time || !range?.end_time) return;
if (props.chartType === "heatmap") return;
const axis = options.xAxis;
if (!axis || Array.isArray(axis) || axis.type !== "time") return;
// µs to ms, then into the same coordinate space the converter puts the
// series timestamps in — a zoned Date, or a zoneless ISO string for UTC.
const toAxisValue = (microseconds: number) => {
const ms = microseconds / 1000;
return store.state.timezone !== "UTC"
? toZonedTime(ms, store.state.timezone)
: new Date(ms).toISOString().slice(0, -1);
if (!range?.start_time || !range?.end_time) {
return { start_time: null, end_time: null };
}
return {
start_time: new Date(range.start_time / 1000),
end_time: new Date(range.end_time / 1000),
};
axis.min = toAxisValue(range.start_time);
axis.max = toAxisValue(range.end_time);
};
/**
* `render` awaits the converter, and the watcher below fires it on every
* prop change — so two renders can be in flight at once, and without this
* the SLOWER, staler one paints last. Only the newest may write.
*/
let renderSeq = 0;
const render = async () => {
const seq = ++renderSeq;
if (!props.results?.length) {
chartData.value = { options: null };
return;
}
try {
const converted: any = await convertPromQLData(
buildPanelSchema(),
props.results,
store,
chartPanelRef,
ref(null), // hoveredSeriesState — cards have no cross-panel hover
[], // annotations
);
if (seq !== renderSeq) return;
const options = converted?.options;
if (options) {
// The card clips its content (`overflow-hidden`, fixed height), and
// ECharts renders its tooltip INSIDE the chart container by default —
// so a tooltip near an edge was being sliced off at the card boundary.
// Portal it to <body> so it can overflow the card, and confine it to
// the viewport so it never runs off screen either.
options.tooltip = {
...(options.tooltip ?? {}),
appendToBody: true,
confine: true,
};
pinTimeAxis(options);
if (props.chartType === "heatmap") {
// Keep the colour bar — without it the cells are just colours with
// no scale. Sized down and pinned under the axis so it doesn't
// overlap the plot.
if (options.visualMap) {
options.visualMap = {
...options.visualMap,
show: true,
orient: "horizontal",
left: "center",
bottom: 0,
itemWidth: 10,
itemHeight: 90,
precision: 2,
textStyle: { fontSize: 9 },
};
// Room for the bar, below the x-axis labels.
options.grid = { ...(options.grid ?? {}), bottom: 26 };
}
for (const series of options.series ?? []) {
series.label = { ...(series.label ?? {}), show: false };
}
// Thin the bucket labels to what a card can actually render, but
// ALWAYS keep the top row. A histogram's `+Inf` bucket is the one
// that says "and everything slower than this", and ECharts'
// `hideOverlap` would otherwise drop it.
if (options.yAxis) {
const rowCount = options.yAxis.data?.length ?? 0;
const step = Math.max(1, Math.ceil(rowCount / 8));
options.yAxis.axisLabel = {
...(options.yAxis.axisLabel ?? {}),
fontSize: 9,
width: 46,
overflow: "truncate",
hideOverlap: false,
// Count DOWN from the top row, so `+Inf` is always labelled and
// the spacing stays even.
interval: (index: number) =>
(rowCount - 1 - index) % step === 0,
};
}
if (options.xAxis) {
options.xAxis.axisLabel = {
...(options.xAxis.axisLabel ?? {}),
fontSize: 9,
hideOverlap: true,
};
}
}
}
chartData.value = converted ?? { options: null };
} catch (error: any) {
if (seq !== renderSeq) return;
chartData.value = { options: null };
emit("error", error?.message ?? t("metrics.metricCardChart.failedToRenderChart"));
}
};
const onChartError = (error: any) => emit("error", error);
/**
* Keep the canvas in step with the card.
*
* ChartRenderer only re-lays-out on a WINDOW resize, but a card changes
* width without one — opening a rail panel, or the column count changing on
* a container resize. The canvas would keep its old size and the chart would
* sit stretched or clipped inside it. So watch the card itself and resize
* the ECharts instance ChartRenderer initialized on its own root.
*/
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
if (!chartPanelRef.value || typeof ResizeObserver === "undefined") return;
resizeObserver = new ResizeObserver(() => {
const host = (
chartPanelRef.value as unknown as HTMLElement
)?.querySelector('[data-test="chart-renderer"]');
if (host) echarts.getInstanceByDom(host as HTMLElement)?.resize();
});
resizeObserver.observe(chartPanelRef.value as unknown as HTMLElement);
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
resizeObserver = null;
/**
* The already-fetched results, handed to the panel so it renders WITHOUT
* firing its own query. `metadata` carries the queried window (µs) so the
* converter's `pin_x_axis_to_range` has a range to pin to.
*/
const injectedPromqlData = computed(() => {
if (!props.results?.length) return undefined;
const range = props.timeRange;
return {
data: props.results,
metadata: {
queries: [
{
startTime: range?.start_time,
endTime: range?.end_time,
},
],
},
};
});
// Everything `render` reads.
watch(
() => [
props.results,
props.queries,
props.chartType,
props.color,
props.unit,
props.unitCustom,
props.bucketUnit,
props.bucketUnitCustom,
props.timeRange,
],
render,
{ immediate: true, deep: false },
);
/**
* Stable empty variables object. The card substitutes no variables, and the
* injected-data path skips the variable wait anyway — but the prop is
* required, so keep one identity to avoid churning the panel's watcher.
*/
const variablesData = {};
return { chartPanelRef, chartData, onChartError };
/**
* The panel emits an error object `{ message }`; forward only the message,
* and only when there is one (it also fires with an empty message to RESET
* the parent's error, which the card handles by clearing on new results).
*/
const onPanelError = (error: any) => {
const message = error?.message ?? error;
if (message) emit("error", String(message));
};
return {
panelSchema,
selectedTimeObj,
injectedPromqlData,
variablesData,
onPanelError,
};
},
});
</script>
@@ -73,11 +73,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
size="sm"
data-test="metrics-explorer-mode-workspace"
>
<!-- `favorite-border` (outline), not the filled `favorite`: a filled
heart is the card's ON state and on a tab would read as "already
<!-- `star-outline` (outline), not the filled `star`: a filled star is
the card's ON state and on a tab would read as "already
favourited" rather than "your favourites live here". -->
<template #icon-left>
<OIcon name="favorite-border" size="sm" class="shrink-0" />
<OIcon name="star-outline" size="sm" class="shrink-0" />
</template>
{{ t("metrics.explorer.modeWorkspace") }}
</OToggleGroupItem>
@@ -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",
});
@@ -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);
});
});
});
@@ -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.
@@ -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",
+6 -1
View File
@@ -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: {
@@ -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<string, any>) =>
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,
);
});
});
@@ -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 <body> 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 <body> 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;
}
}
+2 -2
View File
@@ -209,13 +209,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<template #cell-name="{ row, value }">
<span class="inline-flex items-center gap-1">
<!-- One-click favorite toggle — filled rose heart when
<!-- One-click favorite toggle — filled gold star when
favorited, neutral outline otherwise. -->
<OButton
variant="ghost"
size="icon-xs-sq"
:icon-left="
isFavorite(row.id) ? 'favorite' : 'favorite-border'
isFavorite(row.id) ? 'star' : 'star-outline'
"
:class="
isFavorite(row.id) ? 'text-favorite shrink-0' : 'shrink-0'