mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user