From ab7bdd5e3127a4d805fa1b89beb8a2666ea9aecf Mon Sep 17 00:00:00 2001
From: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
Date: Fri, 12 Jun 2026 18:06:21 +0530
Subject: [PATCH] feat: metrics page share url support (#12607)
Design At:
https://github.com/openobserve/designs/blob/main/metrics-page/share-url/version2
---
.../metrics/metricsUrlState.spec.ts | 337 ++++++++++++++
.../composables/metrics/metricsUrlState.ts | 102 +++++
web/src/plugins/metrics/Index.spec.ts | 9 +-
web/src/plugins/metrics/Index.vue | 165 +++++--
web/src/utils/dashboard/urlTimeParams.spec.ts | 175 ++++++++
web/src/utils/dashboard/urlTimeParams.ts | 82 ++++
web/src/utils/metrics/buildMetricsUrl.spec.ts | 183 ++++++++
web/src/utils/metrics/buildMetricsUrl.ts | 84 ++++
.../metrics/metricsParamRegistry.spec.ts | 137 ++++++
web/src/utils/metrics/metricsParamRegistry.ts | 101 +++++
web/src/utils/url/deepLinkParams.spec.ts | 412 ++++++++++++++++++
web/src/utils/url/deepLinkParams.ts | 174 ++++++++
web/src/utils/zincutils.ts | 6 +
13 files changed, 1934 insertions(+), 33 deletions(-)
create mode 100644 web/src/composables/metrics/metricsUrlState.spec.ts
create mode 100644 web/src/composables/metrics/metricsUrlState.ts
create mode 100644 web/src/utils/dashboard/urlTimeParams.spec.ts
create mode 100644 web/src/utils/dashboard/urlTimeParams.ts
create mode 100644 web/src/utils/metrics/buildMetricsUrl.spec.ts
create mode 100644 web/src/utils/metrics/buildMetricsUrl.ts
create mode 100644 web/src/utils/metrics/metricsParamRegistry.spec.ts
create mode 100644 web/src/utils/metrics/metricsParamRegistry.ts
create mode 100644 web/src/utils/url/deepLinkParams.spec.ts
create mode 100644 web/src/utils/url/deepLinkParams.ts
diff --git a/web/src/composables/metrics/metricsUrlState.spec.ts b/web/src/composables/metrics/metricsUrlState.spec.ts
new file mode 100644
index 0000000000..7db66ec53a
--- /dev/null
+++ b/web/src/composables/metrics/metricsUrlState.spec.ts
@@ -0,0 +1,337 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect } from "vitest";
+import {
+ getMetricsConfig,
+ encodeMetricsConfig,
+ decodeMetricsConfig,
+ applyMetricsBlob,
+ applyDeepLinkOverrides,
+ METRICS_BLOB_VERSION,
+} from "./metricsUrlState";
+import { b64EncodeUnicode, b64DecodeUnicodeSafe } from "@/utils/zincutils";
+import { hasAnyDeepLinkParam } from "@/utils/url/deepLinkParams";
+import { METRICS_PARAMS } from "@/utils/metrics/metricsParamRegistry";
+import { queryParamsToSelectedDate } from "@/utils/dashboard/urlTimeParams";
+import { getDefaultDashboardPanelData } from "@/composables/dashboard/useDashboardPanelDefaults";
+import store from "@/stores";
+
+// a realistic panel (real default shape) with some edits to round-trip
+const makePanel = () => {
+ const p = getDefaultDashboardPanelData(store);
+ p.data.id = "panel-1";
+ p.data.title = "My Panel";
+ p.data.description = "desc";
+ p.data.type = "bar";
+ p.data.queryType = "promql";
+ p.data.queries[0].query = "rate(cpu[5m])";
+ p.data.queries[0].fields.stream = "cpu";
+ p.data.queries[0].fields.stream_type = "logs"; // should be forced to metrics on apply
+ return p;
+};
+
+describe("metricsUrlState · getMetricsConfig", () => {
+ it("returns { v, data } with the current blob version", () => {
+ const cfg = getMetricsConfig(makePanel());
+ expect(cfg.v).toBe(METRICS_BLOB_VERSION);
+ expect(cfg.data).toBeTruthy();
+ });
+ it("strips volatile bits (id/title/description)", () => {
+ const cfg = getMetricsConfig(makePanel());
+ expect(cfg.data.id).toBeUndefined();
+ expect(cfg.data.title).toBeUndefined();
+ expect(cfg.data.description).toBeUndefined();
+ });
+ it("keeps the meaningful panel fields", () => {
+ const cfg = getMetricsConfig(makePanel());
+ expect(cfg.data.type).toBe("bar");
+ expect(cfg.data.queryType).toBe("promql");
+ expect(cfg.data.queries[0].query).toBe("rate(cpu[5m])");
+ });
+ it("is a deep, non-reactive clone (mutating the result never touches the source)", () => {
+ const panel = makePanel();
+ const cfg = getMetricsConfig(panel);
+ cfg.data.queries[0].query = "changed";
+ expect(panel.data.queries[0].query).toBe("rate(cpu[5m])");
+ });
+ it("handles a panel with no data", () => {
+ const cfg = getMetricsConfig({} as any);
+ expect(cfg).toEqual({ v: METRICS_BLOB_VERSION, data: {} });
+ });
+});
+
+describe("metricsUrlState · encode/decode round-trip", () => {
+ it("encodes then decodes back to an equivalent blob", () => {
+ const cfg = getMetricsConfig(makePanel());
+ const decoded = decodeMetricsConfig(encodeMetricsConfig(cfg));
+ expect(decoded).toEqual(cfg);
+ });
+ it("encodes to a non-empty string that is not raw JSON", () => {
+ const blob = encodeMetricsConfig(getMetricsConfig(makePanel()));
+ expect(typeof blob).toBe("string");
+ expect(blob.length).toBeGreaterThan(0);
+ expect(blob.startsWith("{")).toBe(false);
+ });
+});
+
+describe("metricsUrlState · decodeMetricsConfig (defensive)", () => {
+ it("returns null for null/empty/undefined", () => {
+ expect(decodeMetricsConfig(null)).toBeNull();
+ expect(decodeMetricsConfig("")).toBeNull();
+ expect(decodeMetricsConfig(undefined)).toBeNull();
+ });
+ it("returns null for invalid base64", () => {
+ expect(decodeMetricsConfig("!!!not-base64!!!")).toBeNull();
+ });
+ it("returns null when the decoded text is not JSON", () => {
+ expect(decodeMetricsConfig(b64EncodeUnicode("hello") as string)).toBeNull();
+ });
+ it("returns null when the blob has no `data`", () => {
+ expect(
+ decodeMetricsConfig(b64EncodeUnicode('{"v":1}') as string),
+ ).toBeNull();
+ });
+ it("returns null for a mismatched version (gate)", () => {
+ const future = encodeMetricsConfig({ v: 999, data: { type: "bar" } });
+ expect(decodeMetricsConfig(future)).toBeNull();
+ });
+ it("returns null for a JSON array (has no `data`)", () => {
+ expect(
+ decodeMetricsConfig(b64EncodeUnicode("[1,2,3]") as string),
+ ).toBeNull();
+ });
+ it("returns null for a JSON primitive", () => {
+ expect(decodeMetricsConfig(b64EncodeUnicode("42") as string)).toBeNull();
+ });
+});
+
+describe("metricsUrlState · applyMetricsBlob", () => {
+ it("applies a valid blob in place and returns true", () => {
+ const blob = encodeMetricsConfig(getMetricsConfig(makePanel()));
+ const target = getDefaultDashboardPanelData(store);
+ const dataRef = target.data;
+ const ok = applyMetricsBlob(blob, target);
+ expect(ok).toBe(true);
+ expect(target.data).toBe(dataRef); // same reactive .data reference
+ expect(target.data.type).toBe("bar");
+ expect(target.data.queries[0].query).toBe("rate(cpu[5m])");
+ });
+ it("forces stream_type=metrics on every query", () => {
+ const blob = encodeMetricsConfig(getMetricsConfig(makePanel()));
+ const target = getDefaultDashboardPanelData(store);
+ applyMetricsBlob(blob, target);
+ expect(target.data.queries[0].fields.stream_type).toBe("metrics");
+ });
+ it("resets layout.currentQueryIndex to 0", () => {
+ const blob = encodeMetricsConfig(getMetricsConfig(makePanel()));
+ const target = getDefaultDashboardPanelData(store);
+ target.layout.currentQueryIndex = 5;
+ applyMetricsBlob(blob, target);
+ expect(target.layout.currentQueryIndex).toBe(0);
+ });
+ it("returns false and leaves the panel untouched for an invalid blob", () => {
+ const target = getDefaultDashboardPanelData(store);
+ target.data.type = "line";
+ const ok = applyMetricsBlob("garbage!!!", target);
+ expect(ok).toBe(false);
+ expect(target.data.type).toBe("line");
+ });
+
+ it("forces stream_type=metrics on EVERY query of a multi-query blob", () => {
+ const src = getDefaultDashboardPanelData(store);
+ src.data.queries.push(JSON.parse(JSON.stringify(src.data.queries[0])));
+ src.data.queries[0].query = "q0";
+ src.data.queries[0].fields.stream_type = "logs";
+ src.data.queries[1].query = "q1";
+ src.data.queries[1].fields.stream_type = "logs";
+ const blob = encodeMetricsConfig(getMetricsConfig(src));
+
+ const target = getDefaultDashboardPanelData(store);
+ applyMetricsBlob(blob, target);
+ expect(target.data.queries).toHaveLength(2);
+ expect(target.data.queries[0].query).toBe("q0");
+ expect(target.data.queries[1].query).toBe("q1");
+ expect(target.data.queries[0].fields.stream_type).toBe("metrics");
+ expect(target.data.queries[1].fields.stream_type).toBe("metrics");
+ });
+});
+
+describe("metricsUrlState · applyDeepLinkOverrides (integration)", () => {
+ it("applies chart_type + stream_name + base64 query onto the panel", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(
+ {
+ chart_type: "bar",
+ stream_name: "cpu",
+ query: b64EncodeUnicode("rate(cpu[5m])") as string,
+ },
+ dpd,
+ );
+ expect(dpd.data.type).toBe("bar");
+ expect(dpd.data.queries[0].fields.stream).toBe("cpu");
+ expect(dpd.data.queries[0].fields.stream_type).toBe("metrics");
+ expect(dpd.data.queries[0].query).toBe("rate(cpu[5m])");
+ expect(dpd.data.queries[0].customQuery).toBe(true);
+ });
+
+ it("builds TWO queries from query.0 + query.1 when there is NO metrics_data", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(
+ {
+ "query.0": b64EncodeUnicode("rate(cpu[5m])") as string,
+ "query.1": b64EncodeUnicode("rate(mem[5m])") as string,
+ },
+ dpd,
+ );
+ expect(dpd.data.queries).toHaveLength(2);
+ expect(dpd.data.queries[0].query).toBe("rate(cpu[5m])");
+ expect(dpd.data.queries[0].customQuery).toBe(true);
+ expect(dpd.data.queries[1].query).toBe("rate(mem[5m])");
+ expect(dpd.data.queries[1].customQuery).toBe(true);
+ });
+
+ it("builds TWO queries from bare query + query.1 when there is NO metrics_data", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(
+ {
+ query: b64EncodeUnicode("rate(cpu[5m])") as string,
+ "query.1": b64EncodeUnicode("rate(mem[5m])") as string,
+ },
+ dpd,
+ );
+ expect(dpd.data.queries).toHaveLength(2);
+ expect(dpd.data.queries[0].query).toBe("rate(cpu[5m])");
+ expect(dpd.data.queries[1].query).toBe("rate(mem[5m])");
+ });
+
+ it("accepts the legacy `stream` alias (real alerts/logs/incidents deep-link)", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ // a real alerts -> metrics link uses `stream` (NOT stream_name) + base64 query
+ applyDeepLinkOverrides(
+ {
+ stream_type: "metrics",
+ stream: "container_cpu_usage",
+ query: b64EncodeUnicode(
+ '(container_cpu_usage{k8s_namespace_name="dev"}) >= 0.5',
+ ) as string,
+ },
+ dpd,
+ );
+ expect(dpd.data.queries[0].fields.stream).toBe("container_cpu_usage");
+ expect(dpd.data.queries[0].fields.stream_type).toBe("metrics");
+ expect(dpd.data.queries[0].query).toBe(
+ '(container_cpu_usage{k8s_namespace_name="dev"}) >= 0.5',
+ );
+ expect(dpd.data.queries[0].customQuery).toBe(true);
+ });
+
+ it("prefers canonical `stream_name` over the legacy `stream` alias when both present", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides({ stream_name: "canonical", stream: "legacy" }, dpd);
+ expect(dpd.data.queries[0].fields.stream).toBe("canonical");
+ });
+
+ it("compacts a leading gap when there is no metrics_data base", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(
+ { "query.1": b64EncodeUnicode("B") as string },
+ dpd,
+ );
+ // no blob -> compactIndices true -> single query at slot 0
+ expect(dpd.data.queries).toHaveLength(1);
+ expect(dpd.data.queries[0].query).toBe("B");
+ });
+
+ it("keeps literal indices (surgical) when a metrics_data base is present", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ // pretend the page already has a 2-query base
+ dpd.data.queries = [
+ { ...dpd.data.queries[0], query: "q0" },
+ { ...JSON.parse(JSON.stringify(dpd.data.queries[0])), query: "q1" },
+ ];
+ applyDeepLinkOverrides(
+ {
+ metrics_data: "present",
+ "query.1": b64EncodeUnicode("OVR") as string,
+ },
+ dpd,
+ );
+ expect(dpd.data.queries).toHaveLength(2);
+ expect(dpd.data.queries[0].query).toBe("q0"); // untouched
+ expect(dpd.data.queries[1].query).toBe("OVR"); // surgical override
+ });
+
+ it("is a no-op for queries when no override params are present", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ dpd.data.queries[0].query = "keep";
+ applyDeepLinkOverrides({ org_identifier: "x" }, dpd);
+ expect(dpd.data.queries).toHaveLength(1);
+ expect(dpd.data.queries[0].query).toBe("keep");
+ });
+});
+
+// Exercises a verbatim, real alerts -> metrics deep-link URL end-to-end:
+// /web/metrics?stream_type=metrics&stream=container_cpu_usage&stream_value=…
+// &from=<µs>&to=<µs>&query=&org_identifier=default&type=alerts&show_histogram=false
+describe("metricsUrlState · real alerts deep-link URL", () => {
+ const URL_STR =
+ "http://localhost:8081/web/metrics?stream_type=metrics&stream=container_cpu_usage" +
+ "&stream_value=container_cpu_usage&from=1781243646093750&to=1781244060000000" +
+ "&query=KGNvbnRhaW5lcl9jcHVfdXNhZ2V7azhzX25hbWVzcGFjZV9uYW1lPSJkZXYifSkgPj0gMC41" +
+ "&org_identifier=default&type=alerts&show_histogram=false";
+
+ // route.query is a flat string map; reproduce it from the URL.
+ const queryFromUrl = (url: string): Record => {
+ const sp = new URL(url).searchParams;
+ const q: Record = {};
+ sp.forEach((v, k) => (q[k] = v));
+ return q;
+ };
+ const query = queryFromUrl(URL_STR);
+ const EXPECTED_QUERY = '(container_cpu_usage{k8s_namespace_name="dev"}) >= 0.5';
+
+ it("decodes the base64 `query` param to the expected PromQL", () => {
+ expect(b64DecodeUnicodeSafe(query.query)).toBe(EXPECTED_QUERY);
+ });
+
+ it("resolves the legacy `stream` alias, the custom query, and forces stream_type=metrics", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(query, dpd);
+ const q0 = dpd.data.queries[0];
+ expect(dpd.data.queries).toHaveLength(1);
+ expect(q0.fields.stream).toBe("container_cpu_usage"); // from the `stream` alias
+ expect(q0.fields.stream_type).toBe("metrics");
+ expect(q0.query).toBe(EXPECTED_QUERY); // base64-decoded
+ expect(q0.customQuery).toBe(true); // a query ⇒ custom, verbatim
+ });
+
+ it("ignores the non-contract params (stream_value, type, show_histogram, stream_type)", () => {
+ const dpd = getDefaultDashboardPanelData(store);
+ expect(() => applyDeepLinkOverrides(query, dpd)).not.toThrow();
+ expect(dpd.data.queries).toHaveLength(1); // no extra queries from junk params
+ });
+
+ it("maps the microsecond from/to to an absolute time selection (preserved verbatim)", () => {
+ const sel = queryParamsToSelectedDate(query);
+ expect(sel.valueType).toBe("absolute");
+ expect(sel.startTime).toBe("1781243646093750");
+ expect(sel.endTime).toBe("1781244060000000");
+ });
+
+ it("triggers auto-run on load (an inbound override param is present)", () => {
+ expect(hasAnyDeepLinkParam(query, METRICS_PARAMS)).toBe(true);
+ });
+});
diff --git a/web/src/composables/metrics/metricsUrlState.ts b/web/src/composables/metrics/metricsUrlState.ts
new file mode 100644
index 0000000000..3b89f953b4
--- /dev/null
+++ b/web/src/composables/metrics/metricsUrlState.ts
@@ -0,0 +1,102 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Metrics panel <-> URL codec: the whole panel as one versioned base64 blob (metrics_data),
+// plus applyDeepLinkOverrides (inbound override params via the registry engine).
+
+import { b64EncodeUnicode, b64DecodeUnicodeSafe } from "@/utils/zincutils";
+import { applyOverridesFromRegistry } from "@/utils/url/deepLinkParams";
+import {
+ METRICS_PARAMS,
+ defaultMetricsQuery,
+} from "@/utils/metrics/metricsParamRegistry";
+
+// bump when the blob payload shape changes; older blobs decode to null
+export const METRICS_BLOB_VERSION = 1;
+
+export interface MetricsBlob {
+ v: number;
+ data: Record;
+}
+
+// volatile bits that must NOT travel in a shared blob
+const VOLATILE_DATA_KEYS = ["id", "title", "description"];
+
+// deep, non-reactive clone of dashboardPanelData.data minus volatile bits
+export const getMetricsConfig = (dashboardPanelData: any): MetricsBlob => {
+ const data = JSON.parse(JSON.stringify(dashboardPanelData?.data ?? {}));
+ for (const key of VOLATILE_DATA_KEYS) delete data[key];
+ return { v: METRICS_BLOB_VERSION, data };
+};
+
+export const encodeMetricsConfig = (blob: MetricsBlob): string => {
+ try {
+ return b64EncodeUnicode(JSON.stringify(blob)) ?? "";
+ } catch (e) {
+ return "";
+ }
+};
+
+// base64 metrics_data -> { v, data }, or null on bad/old/empty input (never throws)
+export const decodeMetricsConfig = (
+ raw: string | null | undefined,
+): MetricsBlob | null => {
+ if (!raw) return null;
+ try {
+ const decoded = b64DecodeUnicodeSafe(raw);
+ if (!decoded) return null;
+ const parsed = JSON.parse(decoded);
+ if (!parsed || typeof parsed !== "object" || !parsed.data) return null;
+ if (parsed.v !== METRICS_BLOB_VERSION) return null; // version gate / migration hook
+ return parsed as MetricsBlob;
+ } catch (e) {
+ return null;
+ }
+};
+
+// apply a decoded blob onto the live panel IN PLACE (never reassign .data); returns true if applied
+export const applyMetricsBlob = (
+ raw: string | null | undefined,
+ dashboardPanelData: any,
+): boolean => {
+ const blob = decodeMetricsConfig(raw);
+ if (!blob) return false;
+
+ const data = blob.data ?? {};
+ if (Array.isArray(data.queries)) {
+ for (const q of data.queries) {
+ if (q && q.fields) q.fields.stream_type = "metrics";
+ }
+ }
+
+ Object.assign(dashboardPanelData.data, data);
+ if (dashboardPanelData.layout) dashboardPanelData.layout.currentQueryIndex = 0;
+ return true;
+};
+
+// apply inbound deep-link override params on top of the current panel
+export const applyDeepLinkOverrides = (
+ query: Record,
+ dashboardPanelData: any,
+): void => {
+ applyOverridesFromRegistry(METRICS_PARAMS, query, dashboardPanelData, {
+ makeDefaultQuery: defaultMetricsQuery,
+ // no blob base -> compact (build from scratch); with a blob -> literal index (surgical)
+ compactIndices: !query.metrics_data,
+ onIndexApplied: (slot) => {
+ if (slot?.fields) slot.fields.stream_type = "metrics";
+ },
+ });
+};
diff --git a/web/src/plugins/metrics/Index.spec.ts b/web/src/plugins/metrics/Index.spec.ts
index 2009159a56..7b91b8f79e 100644
--- a/web/src/plugins/metrics/Index.spec.ts
+++ b/web/src/plugins/metrics/Index.spec.ts
@@ -34,7 +34,11 @@ vi.mock("vuex", async (importOriginal) => {
});
vi.mock("vue-router", () => ({
- useRouter: () => ({ push: vi.fn() }),
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(() => Promise.resolve()),
+ }),
+ useRoute: () => ({ query: {}, fullPath: "/metrics" }),
}));
const mockShowErrorNotification = vi.fn();
@@ -139,6 +143,9 @@ const createWrapper = (props: Record = {}) => {
props,
global: {
stubs: {
+ // ShareButton pulls in the vuex store (store.commit) + short_url; the
+ // metrics page tests don't exercise it (it has its own spec).
+ ShareButton: true,
// Heavy async / complex children
PanelEditor: {
template:
diff --git a/web/src/plugins/metrics/Index.vue b/web/src/plugins/metrics/Index.vue
index ffaecc3fdc..3cf4f0e450 100644
--- a/web/src/plugins/metrics/Index.vue
+++ b/web/src/plugins/metrics/Index.vue
@@ -52,6 +52,14 @@ along with this program. If not, see .
class="dashboard-icons"
data-test="metrics-auto-refresh"
/>
+
@@ -134,6 +142,22 @@ import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/check
import { PanelEditor } from "@/components/dashboards/PanelEditor";
import { saveMetricsStream, restoreMetricsStream } from "@/utils/streamPersist";
import useDefaultPanelFields from "@/composables/dashboard/useDefaultPanelFields";
+import { useRoute, useRouter } from "vue-router";
+import ShareButton from "@/components/common/ShareButton.vue";
+import {
+ getMetricsConfig,
+ encodeMetricsConfig,
+ applyMetricsBlob,
+ applyDeepLinkOverrides,
+} from "@/composables/metrics/metricsUrlState";
+import {
+ queryParamsToSelectedDate,
+ selectedDateToQueryParams,
+ refreshLabelToInterval,
+ refreshIntervalToLabel,
+} from "@/utils/dashboard/urlTimeParams";
+import { hasAnyDeepLinkParam } from "@/utils/url/deepLinkParams";
+import { METRICS_PARAMS } from "@/utils/metrics/metricsParamRegistry";
const AddToDashboard = defineAsyncComponent(() => {
return import("./../metrics/AddToDashboard.vue");
@@ -153,6 +177,7 @@ export default defineComponent({
AutoRefreshInterval,
PanelEditor,
OButton,
+ ShareButton,
},
setup(props) {
provide("dashboardPanelDataPageKey", "metrics");
@@ -165,6 +190,8 @@ export default defineComponent({
const chartData = ref();
const { t } = useI18n();
const store = useStore();
+ const route = useRoute();
+ const router = useRouter();
const { showErrorNotification } = useNotifications();
const {
dashboardPanelData,
@@ -194,25 +221,10 @@ export default defineComponent({
data: {},
});
- // this is used to activate the watcher only after on mounted
- let isPanelConfigWatcherActivated = false;
- const isPanelConfigChanged = ref(false);
+ // Not kept-alive: re-mounts each navigation, so onMounted is the only restore point.
+ let pendingAutoRun = false;
- onUnmounted(() => {
- // NOTE: Do NOT call resetDashboardPanelData() here.
- // When org changes, Vue mounts the new component (onBeforeMount) BEFORE
- // unmounting the old one (onUnmounted). Resetting the shared singleton
- // dashboardPanelDataObj["metrics"] here would overwrite the "promql" state
- // that the new instance just set, causing the PromQL query type to
- // disappear after every org switch. The new instance's onBeforeMount
- // already resets and re-initialises the state correctly.
- });
-
- // Initialize state before any child components mount so FieldList.vue sees
- // stream_type = "metrics" from the start, preventing a spurious
- // streams?type=logs request and the double stream-list fetch that results
- // from stream_type changing logs → metrics after children have mounted.
- onBeforeMount(() => {
+ const applyMetricsDefaults = () => {
errorData.errors = [];
editMode.value = false;
resetDashboardPanelData();
@@ -229,16 +241,98 @@ export default defineComponent({
}
}
- // set default chart type as line
dashboardPanelData.data.type = "line";
- // set the default query type as promql for metrics
dashboardPanelData.data.queryType = "promql";
dashboardPanelData.data.queries[0].customQuery = false;
-
- // set the show query bar by default for metrics page
dashboardPanelData.layout.showQueryBar = true;
-
chartData.value = {};
+ };
+
+ // panel -> URL: fresh metrics_data blob + time/refresh, dropping override params (diff-guarded).
+ const syncStateToUrl = () => {
+ const query: Record = {
+ org_identifier: store.state.selectedOrganization.identifier,
+ refresh: refreshIntervalToLabel(refreshInterval.value),
+ ...selectedDateToQueryParams(selectedDate.value),
+ metrics_data: encodeMetricsConfig(getMetricsConfig(dashboardPanelData)),
+ };
+ const changed =
+ Object.keys(query).some(
+ (k) => String(query[k]) !== String(route.query[k] ?? ""),
+ ) || Object.keys(route.query).some((k) => !(k in query));
+ if (changed) router.replace({ query }).catch(() => {});
+ };
+
+ // URL -> panel (blob -> overrides -> time/refresh); returns the auto-run gate.
+ const hydrateFromUrl = (): boolean => {
+ const q = route.query as Record;
+
+ if (q.metrics_data) applyMetricsBlob(q.metrics_data, dashboardPanelData);
+ applyDeepLinkOverrides(q, dashboardPanelData);
+
+ if (q.period || (q.from && q.to)) {
+ selectedDate.value = queryParamsToSelectedDate(q);
+ }
+ if (q.refresh != null) {
+ refreshInterval.value = refreshLabelToInterval(
+ q.refresh,
+ store.state?.zoConfig?.min_auto_refresh_interval || 0,
+ );
+ }
+
+ return !!q.metrics_data || hasAnyDeepLinkParam(q, METRICS_PARAMS);
+ };
+
+ // seed builder-mode slots (a stream but no query) with a starter query before auto-run.
+ const seedBuilderSlots = async () => {
+ const queries = dashboardPanelData.data.queries;
+ for (let i = 0; i < queries.length; i++) {
+ const qq = queries[i];
+ if (qq?.fields?.stream && !qq.customQuery && !qq.query) {
+ dashboardPanelData.layout.currentQueryIndex = i;
+ await applyDefaultPanelFields();
+ }
+ }
+ dashboardPanelData.layout.currentQueryIndex = 0;
+ };
+
+ // Share URL: fresh /metrics link from current editor state, freezing relative period to absolute.
+ const metricsShareUrl = computed(() => {
+ void route.fullPath; // reactive dep on URL
+ const url = new URL(window.location.origin + window.location.pathname);
+ const sp = url.searchParams;
+ sp.set("org_identifier", store.state.selectedOrganization.identifier);
+
+ // freeze the window: getConsumableDateTime() is in MICROSECONDS -> ms
+ const ct: any = dateTimePickerRef.value?.getConsumableDateTime?.();
+ if (ct?.startTime && ct?.endTime) {
+ sp.set("from", String(Math.floor(ct.startTime / 1000)));
+ sp.set("to", String(Math.floor(ct.endTime / 1000)));
+ } else {
+ const tp: any = selectedDateToQueryParams(selectedDate.value);
+ if (tp.period) sp.set("period", tp.period);
+ else if (tp.from != null) {
+ sp.set("from", String(tp.from));
+ sp.set("to", String(tp.to));
+ }
+ }
+
+ sp.set("refresh", refreshIntervalToLabel(refreshInterval.value));
+ sp.set(
+ "metrics_data",
+ encodeMetricsConfig(getMetricsConfig(dashboardPanelData)),
+ );
+ return url.href;
+ });
+
+ // this is used to activate the watcher only after on mounted
+ let isPanelConfigWatcherActivated = false;
+ const isPanelConfigChanged = ref(false);
+
+ // Reset before children mount so FieldList sees stream_type=metrics (avoids a spurious logs stream fetch).
+ onBeforeMount(() => {
+ applyMetricsDefaults();
+ pendingAutoRun = hydrateFromUrl();
});
onMounted(async () => {
@@ -249,15 +343,13 @@ export default defineComponent({
await nextTick();
isPanelConfigWatcherActivated = true;
- // Seed the default builder query on initial load (the PromQL builder only
- // rebuilds on label/operation change, so the bar would otherwise be empty).
- const initialQuery = dashboardPanelData.data.queries[0];
- if (
- initialQuery?.fields?.stream &&
- !initialQuery.customQuery &&
- !initialQuery.query
- ) {
- await applyDefaultPanelFields();
+ await seedBuilderSlots();
+
+ // auto-run a restored blob / inbound deep-link, then normalize the URL
+ if (pendingAutoRun) {
+ pendingAutoRun = false;
+ updateDateTime(selectedDate.value);
+ runQuery();
}
});
@@ -266,6 +358,11 @@ export default defineComponent({
async () => {
await nextTick();
chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data));
+
+ // chart-type change re-renders outside runQuery(); re-sync only for an established view (metrics_data present).
+ if (isPanelConfigWatcherActivated && route.query.metrics_data) {
+ syncStateToUrl();
+ }
},
);
@@ -332,6 +429,9 @@ export default defineComponent({
if (panelEditorRef.value) {
panelEditorRef.value.runQuery();
}
+
+ // panel -> URL (full blob + time/refresh); normalizes any inbound params.
+ syncStateToUrl();
};
const updateDateTime = (value: object) => {
@@ -522,6 +622,7 @@ export default defineComponent({
addToDashboard,
refreshInterval,
panelEditorRef,
+ metricsShareUrl,
};
},
});
diff --git a/web/src/utils/dashboard/urlTimeParams.spec.ts b/web/src/utils/dashboard/urlTimeParams.spec.ts
new file mode 100644
index 0000000000..d8f80074ac
--- /dev/null
+++ b/web/src/utils/dashboard/urlTimeParams.spec.ts
@@ -0,0 +1,175 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect } from "vitest";
+import {
+ queryParamsToSelectedDate,
+ selectedDateToQueryParams,
+ refreshLabelToInterval,
+ refreshIntervalToLabel,
+} from "./urlTimeParams";
+
+describe("urlTimeParams · queryParamsToSelectedDate", () => {
+ it("maps `period` to a relative selection", () => {
+ expect(queryParamsToSelectedDate({ period: "1h" })).toEqual({
+ valueType: "relative",
+ startTime: null,
+ endTime: null,
+ relativeTimePeriod: "1h",
+ });
+ });
+
+ it("maps `from`+`to` to an absolute selection", () => {
+ expect(queryParamsToSelectedDate({ from: "1000", to: "2000" })).toEqual({
+ valueType: "absolute",
+ startTime: "1000",
+ endTime: "2000",
+ relativeTimePeriod: "15m",
+ });
+ });
+
+ it("defaults to relative 15m when neither is present", () => {
+ expect(queryParamsToSelectedDate({})).toEqual({
+ valueType: "relative",
+ startTime: null,
+ endTime: null,
+ relativeTimePeriod: "15m",
+ });
+ });
+
+ it("prefers `period` when both period and from/to are present", () => {
+ const r = queryParamsToSelectedDate({
+ period: "5m",
+ from: "1000",
+ to: "2000",
+ });
+ expect(r.valueType).toBe("relative");
+ expect(r.relativeTimePeriod).toBe("5m");
+ });
+
+ it("falls back to relative default when only `from` is present (no `to`)", () => {
+ const r = queryParamsToSelectedDate({ from: "1000" });
+ expect(r.valueType).toBe("relative");
+ expect(r.relativeTimePeriod).toBe("15m");
+ });
+});
+
+describe("urlTimeParams · selectedDateToQueryParams", () => {
+ it("returns {} for null/undefined", () => {
+ expect(selectedDateToQueryParams(null)).toEqual({});
+ expect(selectedDateToQueryParams(undefined)).toEqual({});
+ });
+
+ it("relative -> { period }", () => {
+ expect(
+ selectedDateToQueryParams({
+ valueType: "relative",
+ relativeTimePeriod: "1h",
+ startTime: null,
+ endTime: null,
+ }),
+ ).toEqual({ period: "1h" });
+ });
+
+ it("absolute -> { from, to }", () => {
+ expect(
+ selectedDateToQueryParams({
+ valueType: "absolute",
+ startTime: 1000,
+ endTime: 2000,
+ relativeTimePeriod: "",
+ }),
+ ).toEqual({ from: 1000, to: 2000 });
+ });
+
+ it("falls back to { period } when valueType is missing but relativeTimePeriod is set", () => {
+ expect(
+ selectedDateToQueryParams({
+ relativeTimePeriod: "30m",
+ } as any),
+ ).toEqual({ period: "30m" });
+ });
+
+ it("falls back to { from, to } when valueType is missing but start/end are set", () => {
+ expect(
+ selectedDateToQueryParams({
+ startTime: 10,
+ endTime: 20,
+ } as any),
+ ).toEqual({ from: 10, to: 20 });
+ });
+
+ it("returns {} for an empty selection", () => {
+ expect(selectedDateToQueryParams({} as any)).toEqual({});
+ });
+
+ it("round-trips with queryParamsToSelectedDate (relative)", () => {
+ const params = { period: "1h" };
+ expect(
+ selectedDateToQueryParams(queryParamsToSelectedDate(params)),
+ ).toEqual(params);
+ });
+});
+
+describe("urlTimeParams · refreshLabelToInterval", () => {
+ it("parses a label to seconds", () => {
+ expect(refreshLabelToInterval("30s")).toBe(30);
+ expect(refreshLabelToInterval("1m")).toBe(60);
+ expect(refreshLabelToInterval("1h")).toBe(3600);
+ });
+
+ it("parses larger units (d/w)", () => {
+ expect(refreshLabelToInterval("1d")).toBe(86400);
+ expect(refreshLabelToInterval("1w")).toBe(604800);
+ });
+
+ it("clamps to 0 (off) when below the configured minimum", () => {
+ expect(refreshLabelToInterval("3s", 5)).toBe(0);
+ });
+
+ it("keeps the value when at/above the minimum", () => {
+ expect(refreshLabelToInterval("10s", 5)).toBe(10);
+ expect(refreshLabelToInterval("5s", 5)).toBe(5);
+ });
+
+ it("returns 0 for empty/nullish labels", () => {
+ expect(refreshLabelToInterval("")).toBe(0);
+ expect(refreshLabelToInterval(null)).toBe(0);
+ expect(refreshLabelToInterval(undefined)).toBe(0);
+ });
+
+ it("returns 0 for an unparseable label like 'Off'", () => {
+ expect(refreshLabelToInterval("Off")).toBe(0);
+ });
+
+ it("does not clamp when no minimum is given", () => {
+ expect(refreshLabelToInterval("3s")).toBe(3);
+ });
+});
+
+describe("urlTimeParams · refreshIntervalToLabel", () => {
+ it("formats seconds to a label", () => {
+ expect(refreshIntervalToLabel(30)).toBe("30s");
+ expect(refreshIntervalToLabel(60)).toBe("1m");
+ });
+
+ it("formats 0 as Off", () => {
+ expect(refreshIntervalToLabel(0)).toBe("Off");
+ });
+
+ it("round-trips with refreshLabelToInterval for non-zero values", () => {
+ expect(refreshLabelToInterval(refreshIntervalToLabel(300))).toBe(300);
+ });
+});
diff --git a/web/src/utils/dashboard/urlTimeParams.ts b/web/src/utils/dashboard/urlTimeParams.ts
new file mode 100644
index 0000000000..eebdf9c9fd
--- /dev/null
+++ b/web/src/utils/dashboard/urlTimeParams.ts
@@ -0,0 +1,82 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Shared URL <-> time-range / refresh helpers (simple date-picker shape), lifted from ViewDashboard.
+// ⚠️ Not the rich-shape date.ts helpers — those expect {tab,relative,absolute} and throw on this shape.
+
+import type { LocationQuery } from "vue-router";
+import { parseDuration, generateDurationLabel } from "@/utils/date";
+
+export interface SelectedDate {
+ valueType: "relative" | "absolute";
+ startTime: number | string | null;
+ endTime: number | string | null;
+ relativeTimePeriod: string;
+}
+
+export type TimeQueryParams =
+ | { period: string }
+ | { from: number | string; to: number | string }
+ | Record;
+
+// URL params -> selectedDate (period wins, else from/to, else relative 15m).
+export const queryParamsToSelectedDate = (
+ params: LocationQuery | Record,
+): SelectedDate => ({
+ valueType: params.period
+ ? "relative"
+ : params.from && params.to
+ ? "absolute"
+ : "relative",
+ startTime: params.from ? params.from : null,
+ endTime: params.to ? params.to : null,
+ relativeTimePeriod: params.period ? (params.period as string) : "15m",
+});
+
+// selectedDate -> URL params: relative -> {period}, absolute -> {from,to}.
+export const selectedDateToQueryParams = (
+ data: SelectedDate | null | undefined,
+): TimeQueryParams => {
+ if (!data) return {};
+
+ if (data.valueType === "relative" && data.relativeTimePeriod) {
+ return { period: data.relativeTimePeriod };
+ } else if (data.valueType === "absolute" && data.startTime && data.endTime) {
+ return { from: data.startTime, to: data.endTime };
+ }
+
+ // fallback when valueType is missing
+ if (data.relativeTimePeriod) {
+ return { period: data.relativeTimePeriod };
+ } else if (data.startTime && data.endTime) {
+ return { from: data.startTime, to: data.endTime };
+ }
+
+ return {};
+};
+
+// refresh label (e.g. "30s") -> seconds, clamped to 0 (off) below the configured minimum.
+export const refreshLabelToInterval = (
+ label: string | undefined | null,
+ minRefreshInterval = 0,
+): number => {
+ if (!label) return 0;
+ const secs = parseDuration(label);
+ if (minRefreshInterval && secs < minRefreshInterval) return 0;
+ return secs;
+};
+
+export const refreshIntervalToLabel = (seconds: number): string =>
+ generateDurationLabel(seconds);
diff --git a/web/src/utils/metrics/buildMetricsUrl.spec.ts b/web/src/utils/metrics/buildMetricsUrl.spec.ts
new file mode 100644
index 0000000000..425a2fda85
--- /dev/null
+++ b/web/src/utils/metrics/buildMetricsUrl.spec.ts
@@ -0,0 +1,183 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// `buildMetricsUrl` is pure (real registry + real base64). `openMetricsDeepLink`
+// is network + window.open glue (external I/O) and is left to e2e coverage.
+import { describe, it, expect } from "vitest";
+import { buildMetricsUrl } from "./buildMetricsUrl";
+import { b64EncodeUnicode } from "@/utils/zincutils";
+import { applyDeepLinkOverrides } from "@/composables/metrics/metricsUrlState";
+import { getDefaultDashboardPanelData } from "@/composables/dashboard/useDashboardPanelDefaults";
+import store from "@/stores";
+
+const BASE = "https://o2.example/web";
+const build = (intent: any) =>
+ buildMetricsUrl({ orgId: "default", baseUrl: BASE, ...intent });
+
+describe("buildMetricsUrl", () => {
+ it("targets /metrics under the given base and sets org_identifier", () => {
+ const url = build({});
+ expect(url.pathname).toBe("/web/metrics");
+ expect(url.searchParams.get("org_identifier")).toBe("default");
+ });
+
+ it("emits the metrics_data base blob when provided", () => {
+ expect(build({ base: "BLOB" }).searchParams.get("metrics_data")).toBe(
+ "BLOB",
+ );
+ });
+
+ it("emits a relative period", () => {
+ expect(
+ build({ time: { period: "15m" } }).searchParams.get("period"),
+ ).toBe("15m");
+ });
+
+ it("emits absolute from/to", () => {
+ const sp = build({ time: { from: 1000, to: 2000 } }).searchParams;
+ expect(sp.get("from")).toBe("1000");
+ expect(sp.get("to")).toBe("2000");
+ });
+
+ it("emits refresh", () => {
+ expect(build({ refresh: "30s" }).searchParams.get("refresh")).toBe("30s");
+ });
+
+ it("emits chart_type and query_type", () => {
+ const sp = build({ chartType: "bar", queryType: "sql" }).searchParams;
+ expect(sp.get("chart_type")).toBe("bar");
+ expect(sp.get("query_type")).toBe("sql");
+ });
+
+ it("emits a single query with bare keys (index 0) and base64-encodes the query", () => {
+ const sp = build({
+ queries: [{ stream: "cpu", query: "rate(cpu[5m])" }],
+ }).searchParams;
+ expect(sp.get("stream_name")).toBe("cpu");
+ expect(sp.has("stream_name.0")).toBe(false);
+ expect(sp.get("query")).toBe(b64EncodeUnicode("rate(cpu[5m])"));
+ expect(sp.get("query")).not.toBe("rate(cpu[5m])");
+ });
+
+ it("emits multiple queries (bare for q0, .1 for q1)", () => {
+ const sp = build({
+ queries: [
+ { stream: "cpu", query: "a" },
+ { stream: "mem", query: "b" },
+ ],
+ }).searchParams;
+ expect(sp.get("stream_name")).toBe("cpu");
+ expect(sp.get("stream_name.1")).toBe("mem");
+ expect(sp.get("query")).toBe(b64EncodeUnicode("a"));
+ expect(sp.get("query.1")).toBe(b64EncodeUnicode("b"));
+ });
+
+ it("emits no per-query params when there are no queries", () => {
+ const sp = build({ chartType: "line" }).searchParams;
+ expect(sp.has("stream_name")).toBe(false);
+ expect(sp.has("query")).toBe(false);
+ });
+
+ it("round-trips through toString()/new URL()", () => {
+ const url = build({ chartType: "bar", queries: [{ stream: "cpu" }] });
+ const reparsed = new URL(url.toString());
+ expect(reparsed.searchParams.get("chart_type")).toBe("bar");
+ expect(reparsed.searchParams.get("stream_name")).toBe("cpu");
+ });
+
+ it("emits a full link (base + time + refresh + types + multi-query) together", () => {
+ const sp = build({
+ base: "BLOB",
+ time: { from: 100, to: 200 },
+ refresh: "30s",
+ chartType: "bar",
+ queryType: "promql",
+ queries: [
+ { stream: "cpu", query: "a" },
+ { stream: "mem", query: "b" },
+ ],
+ }).searchParams;
+ expect(sp.get("org_identifier")).toBe("default");
+ expect(sp.get("metrics_data")).toBe("BLOB");
+ expect(sp.get("from")).toBe("100");
+ expect(sp.get("to")).toBe("200");
+ expect(sp.get("refresh")).toBe("30s");
+ expect(sp.get("chart_type")).toBe("bar");
+ expect(sp.get("query_type")).toBe("promql");
+ expect(sp.get("stream_name")).toBe("cpu");
+ expect(sp.get("stream_name.1")).toBe("mem");
+ expect(sp.get("query")).toBe(b64EncodeUnicode("a"));
+ expect(sp.get("query.1")).toBe(b64EncodeUnicode("b"));
+ });
+});
+
+// End-to-end: a link produced by buildMetricsUrl must reconstruct the same
+// intent when consumed by applyDeepLinkOverrides (build <-> apply round-trip).
+describe("buildMetricsUrl <-> applyDeepLinkOverrides round-trip", () => {
+ const toQueryObject = (url: URL) => {
+ const q: Record = {};
+ url.searchParams.forEach((v, k) => (q[k] = v));
+ return q;
+ };
+
+ it("reconstructs a multi-query intent (chart type, query type, streams, queries)", () => {
+ const url = buildMetricsUrl({
+ orgId: "default",
+ baseUrl: BASE,
+ chartType: "bar",
+ queryType: "promql",
+ queries: [
+ { stream: "cpu", query: "rate(cpu[5m])" },
+ { stream: "mem", query: "rate(mem[5m])" },
+ ],
+ });
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(toQueryObject(url), dpd);
+
+ expect(dpd.data.type).toBe("bar");
+ expect(dpd.data.queryType).toBe("promql");
+ expect(dpd.data.queries).toHaveLength(2);
+ expect(dpd.data.queries[0].fields.stream).toBe("cpu");
+ expect(dpd.data.queries[0].query).toBe("rate(cpu[5m])");
+ expect(dpd.data.queries[0].customQuery).toBe(true);
+ expect(dpd.data.queries[1].fields.stream).toBe("mem");
+ expect(dpd.data.queries[1].query).toBe("rate(mem[5m])");
+ });
+
+ it("reconstructs a builder-mode single-query intent (stream only)", () => {
+ const url = buildMetricsUrl({
+ orgId: "default",
+ baseUrl: BASE,
+ queries: [{ stream: "cpu" }],
+ });
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(toQueryObject(url), dpd);
+
+ expect(dpd.data.queries[0].fields.stream).toBe("cpu");
+ expect(dpd.data.queries[0].customQuery).toBe(false); // builder, no query
+ });
+
+ it("survives a query with PromQL special chars through base64", () => {
+ const raw = 'sum(rate(http_requests_total{job="api",code="500"}[5m]))';
+ const url = buildMetricsUrl({
+ orgId: "default",
+ baseUrl: BASE,
+ queries: [{ query: raw }],
+ });
+ const dpd = getDefaultDashboardPanelData(store);
+ applyDeepLinkOverrides(toQueryObject(url), dpd);
+ expect(dpd.data.queries[0].query).toBe(raw);
+ });
+});
diff --git a/web/src/utils/metrics/buildMetricsUrl.ts b/web/src/utils/metrics/buildMetricsUrl.ts
new file mode 100644
index 0000000000..69c4cc4609
--- /dev/null
+++ b/web/src/utils/metrics/buildMetricsUrl.ts
@@ -0,0 +1,84 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Build a deep-link INTO the Metrics page: owns the param names so callers never hardcode URL keys.
+
+import shortURL from "@/services/short_url";
+import { buildUrlFromRegistry } from "@/utils/url/deepLinkParams";
+import { METRICS_PARAMS } from "@/utils/metrics/metricsParamRegistry";
+
+export interface MetricsQueryIntent {
+ stream?: string;
+ query?: string; // raw PromQL/SQL (NOT base64 — the registry encodes it)
+}
+
+export interface MetricsLinkIntent {
+ orgId: string;
+ base?: string; // optional metrics_data blob to layer overrides on
+ queries?: MetricsQueryIntent[];
+ chartType?: string;
+ queryType?: "promql" | "sql";
+ time?: { period?: string } | { from: string | number; to: string | number };
+ refresh?: string;
+ baseUrl?: string;
+}
+
+// app web base (handles the optional /web sub-path), mirroring onLogPanel
+const webBase = (): string => {
+ const pos = window.location.pathname.indexOf("/web/");
+ return pos > -1
+ ? window.location.origin + window.location.pathname.slice(0, pos) + "/web"
+ : window.location.origin;
+};
+
+export const buildMetricsUrl = (intent: MetricsLinkIntent): URL => {
+ const url = new URL((intent.baseUrl ?? webBase()) + "/metrics");
+ const p = url.searchParams;
+
+ p.set("org_identifier", intent.orgId);
+ if (intent.base) p.set("metrics_data", intent.base);
+
+ const time: any = intent.time;
+ if (time?.period) {
+ p.set("period", String(time.period));
+ } else if (time?.from != null && time?.to != null) {
+ p.set("from", String(time.from));
+ p.set("to", String(time.to));
+ }
+ if (intent.refresh) p.set("refresh", intent.refresh);
+
+ buildUrlFromRegistry(url, METRICS_PARAMS, intent);
+
+ return url;
+};
+
+// build the deep-link, shorten via short_url, and open it (new tab by default); long-URL fallback
+export const openMetricsDeepLink = async (
+ intent: MetricsLinkIntent,
+ opts: { newTab?: boolean } = {},
+): Promise => {
+ const newTab = opts.newTab ?? true;
+ const longUrl = buildMetricsUrl(intent).toString();
+ let target = longUrl;
+ try {
+ const res = await shortURL.create(intent.orgId, longUrl);
+ target = res?.data?.short_url || longUrl;
+ } catch (e) {
+ target = longUrl;
+ }
+ if (newTab) window.open(target, "_blank");
+ else window.location.href = target;
+ return target;
+};
diff --git a/web/src/utils/metrics/metricsParamRegistry.spec.ts b/web/src/utils/metrics/metricsParamRegistry.spec.ts
new file mode 100644
index 0000000000..88a40635b0
--- /dev/null
+++ b/web/src/utils/metrics/metricsParamRegistry.spec.ts
@@ -0,0 +1,137 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect } from "vitest";
+import {
+ sanitizeChartType,
+ defaultMetricsQuery,
+ METRICS_PARAMS,
+} from "./metricsParamRegistry";
+import { b64EncodeUnicode } from "@/utils/zincutils";
+import type { ParamDescriptor } from "@/utils/url/deepLinkParams";
+
+const byKey = (key: string) =>
+ METRICS_PARAMS.find((d) => d.key === key) as ParamDescriptor;
+
+describe("metricsParamRegistry · sanitizeChartType", () => {
+ it.each(["line", "bar", "area", "scatter", "table", "gauge", "h-bar"])(
+ "accepts the valid chart type %s",
+ (t) => {
+ expect(sanitizeChartType(t)).toBe(t);
+ },
+ );
+ it("rejects an unknown chart type", () => {
+ expect(sanitizeChartType("not-a-chart")).toBeNull();
+ });
+ it("rejects an empty string", () => {
+ expect(sanitizeChartType("")).toBeNull();
+ });
+});
+
+describe("metricsParamRegistry · defaultMetricsQuery", () => {
+ it("forces stream_type to metrics (not the logs default)", () => {
+ expect(defaultMetricsQuery().fields.stream_type).toBe("metrics");
+ });
+ it("has the expected query slot shape", () => {
+ const q = defaultMetricsQuery();
+ expect(q).toHaveProperty("query");
+ expect(q).toHaveProperty("customQuery");
+ expect(q).toHaveProperty("fields");
+ expect(q).toHaveProperty("config");
+ });
+ it("returns a fresh clone each call (no shared reference)", () => {
+ const a = defaultMetricsQuery();
+ const b = defaultMetricsQuery();
+ expect(a).not.toBe(b);
+ a.fields.stream = "mutated";
+ expect(b.fields.stream).not.toBe("mutated");
+ });
+});
+
+describe("metricsParamRegistry · chart_type descriptor", () => {
+ const d = byKey("chart_type");
+ it("is panel-scoped", () => expect(d.scope).toBe("panel"));
+ it("applies a valid type to data.type", () => {
+ const dpd: any = { data: { type: "line" } };
+ d.apply(dpd, "bar", {});
+ expect(dpd.data.type).toBe("bar");
+ });
+ it("ignores an invalid type (keeps base)", () => {
+ const dpd: any = { data: { type: "line" } };
+ d.apply(dpd, "nope", {});
+ expect(dpd.data.type).toBe("line");
+ });
+ it("reads chartType from the intent", () => {
+ expect(d.read?.({ chartType: "area" })).toBe("area");
+ });
+});
+
+describe("metricsParamRegistry · query_type descriptor", () => {
+ const d = byKey("query_type");
+ it("is panel-scoped", () => expect(d.scope).toBe("panel"));
+ it("maps 'sql' to sql", () => {
+ const dpd: any = { data: { queryType: "promql" } };
+ d.apply(dpd, "sql", {});
+ expect(dpd.data.queryType).toBe("sql");
+ });
+ it("maps any non-sql value to promql", () => {
+ const dpd: any = { data: { queryType: "sql" } };
+ d.apply(dpd, "whatever", {});
+ expect(dpd.data.queryType).toBe("promql");
+ });
+ it("reads queryType from the intent", () => {
+ expect(d.read?.({ queryType: "sql" })).toBe("sql");
+ });
+});
+
+describe("metricsParamRegistry · stream_name descriptor", () => {
+ const d = byKey("stream_name");
+ it("is per-query-scoped", () => expect(d.scope).toBe("perQuery"));
+ it("sets fields.stream and forces stream_type=metrics", () => {
+ const slot: any = { fields: { stream: "", stream_type: "logs" } };
+ d.apply(slot, "cpu", {});
+ expect(slot.fields.stream).toBe("cpu");
+ expect(slot.fields.stream_type).toBe("metrics");
+ });
+ it("creates the fields object if missing", () => {
+ const slot: any = {};
+ d.apply(slot, "cpu", {});
+ expect(slot.fields.stream).toBe("cpu");
+ });
+ it("reads stream from the per-query intent", () => {
+ expect(d.read?.({ stream: "mem" })).toBe("mem");
+ });
+});
+
+describe("metricsParamRegistry · query descriptor", () => {
+ const d = byKey("query");
+ it("is per-query-scoped", () => expect(d.scope).toBe("perQuery"));
+ it("base64-decodes and marks the slot custom", () => {
+ const slot: any = { query: "", customQuery: false };
+ d.apply(slot, d.decode?.(b64EncodeUnicode("rate(cpu[5m])") as string), {});
+ expect(slot.query).toBe("rate(cpu[5m])");
+ expect(slot.customQuery).toBe(true);
+ });
+ it("encode/decode round-trips a query string", () => {
+ const raw = 'sum(rate(http_requests_total{job="api"}[5m]))';
+ expect(d.decode?.(d.encode?.(raw) as string)).toBe(raw);
+ });
+ it("decodes invalid base64 to an empty string (never throws)", () => {
+ expect(d.decode?.("!!!not-base64!!!")).toBe("");
+ });
+ it("reads query from the per-query intent", () => {
+ expect(d.read?.({ query: "up" })).toBe("up");
+ });
+});
diff --git a/web/src/utils/metrics/metricsParamRegistry.ts b/web/src/utils/metrics/metricsParamRegistry.ts
new file mode 100644
index 0000000000..8b9d29d15f
--- /dev/null
+++ b/web/src/utils/metrics/metricsParamRegistry.ts
@@ -0,0 +1,101 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Metrics deep-link param registry: the inbound override params the page accepts (consumed by deepLinkParams).
+// time (from/to/period) and refresh are not descriptors — they ride urlTimeParams.
+
+import store from "@/stores";
+import { getDefaultDashboardPanelData } from "@/composables/dashboard/useDashboardPanelDefaults";
+import { b64EncodeUnicode, b64DecodeUnicodeSafe } from "@/utils/zincutils";
+import type { ParamDescriptor } from "@/utils/url/deepLinkParams";
+
+// valid panel chart types (ChartSelection.vue ids); unknown -> ignored
+const VALID_CHART_TYPES = new Set([
+ "area",
+ "area-stacked",
+ "bar",
+ "h-bar",
+ "line",
+ "scatter",
+ "stacked",
+ "h-stacked",
+ "geomap",
+ "maps",
+ "pie",
+ "donut",
+ "heatmap",
+ "table",
+ "metric",
+ "gauge",
+ "html",
+ "markdown",
+ "sankey",
+ "custom_chart",
+]);
+
+export const sanitizeChartType = (raw: string): string | null =>
+ VALID_CHART_TYPES.has(raw) ? raw : null;
+
+// a fresh metrics query slot (cloned from the panel default, stream_type=metrics)
+export const defaultMetricsQuery = (): any => {
+ const q = JSON.parse(
+ JSON.stringify(getDefaultDashboardPanelData(store).data.queries[0]),
+ );
+ if (q?.fields) q.fields.stream_type = "metrics";
+ return q;
+};
+
+export const METRICS_PARAMS: ParamDescriptor[] = [
+ {
+ key: "chart_type",
+ scope: "panel",
+ apply: (dpd, value) => {
+ const t = sanitizeChartType(String(value));
+ if (t) dpd.data.type = t;
+ },
+ read: (intent) => intent?.chartType,
+ },
+ {
+ key: "query_type",
+ scope: "panel",
+ apply: (dpd, value) => {
+ dpd.data.queryType = value === "sql" ? "sql" : "promql";
+ },
+ read: (intent) => intent?.queryType,
+ },
+ {
+ key: "stream_name",
+ aliases: ["stream"], // legacy: logs/alerts/incidents emit `stream`
+ scope: "perQuery",
+ apply: (slot, value) => {
+ if (!slot.fields) slot.fields = {};
+ slot.fields.stream = value;
+ slot.fields.stream_type = "metrics";
+ },
+ read: (q) => q?.stream,
+ },
+ {
+ key: "query",
+ scope: "perQuery",
+ // base64 of the raw PromQL/SQL; presence ⇒ custom, honored verbatim
+ decode: (raw) => b64DecodeUnicodeSafe(raw),
+ encode: (value) => b64EncodeUnicode(String(value)),
+ apply: (slot, value) => {
+ slot.query = value;
+ slot.customQuery = true;
+ },
+ read: (q) => q?.query,
+ },
+];
diff --git a/web/src/utils/url/deepLinkParams.spec.ts b/web/src/utils/url/deepLinkParams.spec.ts
new file mode 100644
index 0000000000..4713760abb
--- /dev/null
+++ b/web/src/utils/url/deepLinkParams.spec.ts
@@ -0,0 +1,412 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Engine tests use the REAL metrics registry + REAL base64 helpers (no mocks)
+// so encode/decode and descriptor apply/read are exercised exactly as in prod.
+import { describe, it, expect } from "vitest";
+import {
+ indexedKey,
+ readIndexed,
+ parseQueryIndices,
+ hasAnyDeepLinkParam,
+ buildUrlFromRegistry,
+ applyOverridesFromRegistry,
+} from "./deepLinkParams";
+import {
+ METRICS_PARAMS,
+ defaultMetricsQuery,
+} from "@/utils/metrics/metricsParamRegistry";
+import { b64EncodeUnicode } from "@/utils/zincutils";
+
+const PERQ = METRICS_PARAMS.filter((d) => d.scope === "perQuery");
+const enc = (s: string) => b64EncodeUnicode(s) as string;
+
+const mkState = (queries: any[] = [defaultMetricsQuery()]) => ({
+ data: { type: "line", queryType: "promql", queries },
+ layout: { currentQueryIndex: 3 },
+});
+
+describe("deepLinkParams · indexedKey", () => {
+ it("returns the bare key for index 0 (the alias)", () => {
+ expect(indexedKey("query", 0)).toBe("query");
+ });
+ it("returns key.i for index >= 1", () => {
+ expect(indexedKey("query", 1)).toBe("query.1");
+ expect(indexedKey("stream_name", 5)).toBe("stream_name.5");
+ });
+});
+
+describe("deepLinkParams · readIndexed", () => {
+ it("treats the bare key as index 0", () => {
+ expect(readIndexed({ query: "a" }, "query", 0)).toBe("a");
+ });
+ it("reads the explicit .i key", () => {
+ expect(readIndexed({ "query.1": "b" }, "query", 1)).toBe("b");
+ });
+ it("prefers the explicit .0 over the bare key (.0 wins)", () => {
+ expect(readIndexed({ query: "bare", "query.0": "dot0" }, "query", 0)).toBe(
+ "dot0",
+ );
+ });
+ it("does NOT treat the bare key as index >= 1", () => {
+ expect(readIndexed({ query: "a" }, "query", 1)).toBeUndefined();
+ });
+ it("returns undefined when the key is absent", () => {
+ expect(readIndexed({}, "query", 0)).toBeUndefined();
+ expect(readIndexed({ other: "x" }, "query", 2)).toBeUndefined();
+ });
+});
+
+describe("deepLinkParams · parseQueryIndices", () => {
+ it("treats the bare key as index 0", () => {
+ expect(parseQueryIndices({ query: "a" }, PERQ)).toEqual([0]);
+ });
+ it("treats .0 as index 0", () => {
+ expect(parseQueryIndices({ "query.0": "a" }, PERQ)).toEqual([0]);
+ });
+ it("collects contiguous indices 0,1,2", () => {
+ expect(
+ parseQueryIndices({ query: "a", "query.1": "b", "query.2": "c" }, PERQ),
+ ).toEqual([0, 1, 2]);
+ });
+ it("collects non-contiguous indices (gaps preserved here, compacted at apply)", () => {
+ expect(parseQueryIndices({ "query.2": "c" }, PERQ)).toEqual([2]);
+ expect(parseQueryIndices({ "query.1": "b", "query.3": "d" }, PERQ)).toEqual([
+ 1, 3,
+ ]);
+ });
+ it("unions indices across descriptors and dedupes", () => {
+ expect(
+ parseQueryIndices({ stream_name: "s", "query.1": "q" }, PERQ),
+ ).toEqual([0, 1]);
+ expect(
+ parseQueryIndices({ "stream_name.1": "s", "query.1": "q" }, PERQ),
+ ).toEqual([1]);
+ });
+ it("returns a sorted result regardless of key order", () => {
+ expect(
+ parseQueryIndices({ "query.2": "c", query: "a", "query.1": "b" }, PERQ),
+ ).toEqual([0, 1, 2]);
+ });
+ it("returns [] when no per-query params are present", () => {
+ expect(parseQueryIndices({ chart_type: "bar" }, PERQ)).toEqual([]);
+ });
+});
+
+describe("deepLinkParams · hasAnyDeepLinkParam", () => {
+ it("is true when a panel param is present", () => {
+ expect(hasAnyDeepLinkParam({ chart_type: "bar" }, METRICS_PARAMS)).toBe(
+ true,
+ );
+ });
+ it("is true when a bare per-query param is present", () => {
+ expect(hasAnyDeepLinkParam({ stream_name: "cpu" }, METRICS_PARAMS)).toBe(
+ true,
+ );
+ });
+ it("is true when an indexed per-query param is present", () => {
+ expect(hasAnyDeepLinkParam({ "query.1": "x" }, METRICS_PARAMS)).toBe(true);
+ });
+ it("is false for an empty query", () => {
+ expect(hasAnyDeepLinkParam({}, METRICS_PARAMS)).toBe(false);
+ });
+ it("is false when only unrelated params are present", () => {
+ expect(
+ hasAnyDeepLinkParam(
+ { org_identifier: "x", refresh: "30s", period: "15m" },
+ METRICS_PARAMS,
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("deepLinkParams · buildUrlFromRegistry (real METRICS_PARAMS)", () => {
+ const build = (intent: any) =>
+ buildUrlFromRegistry(
+ new URL("https://o2.dev/metrics"),
+ METRICS_PARAMS,
+ intent,
+ ).searchParams;
+
+ it("emits panel params via read()", () => {
+ const sp = build({ chartType: "bar", queryType: "sql" });
+ expect(sp.get("chart_type")).toBe("bar");
+ expect(sp.get("query_type")).toBe("sql");
+ });
+ it("skips panel params that are null/empty", () => {
+ expect(build({ chartType: "" }).has("chart_type")).toBe(false);
+ expect(build({}).has("chart_type")).toBe(false);
+ });
+ it("emits the first query with the BARE key (index 0)", () => {
+ const sp = build({ queries: [{ stream: "cpu", query: "rate(cpu[5m])" }] });
+ expect(sp.get("stream_name")).toBe("cpu");
+ expect(sp.has("stream_name.0")).toBe(false);
+ });
+ it("emits subsequent queries as .1, .2", () => {
+ const sp = build({
+ queries: [{ stream: "cpu" }, { stream: "mem" }, { stream: "disk" }],
+ });
+ expect(sp.get("stream_name")).toBe("cpu");
+ expect(sp.get("stream_name.1")).toBe("mem");
+ expect(sp.get("stream_name.2")).toBe("disk");
+ });
+ it("base64-encodes the query value", () => {
+ const sp = build({ queries: [{ query: "rate(cpu[5m])" }] });
+ expect(sp.get("query")).toBe(enc("rate(cpu[5m])"));
+ expect(sp.get("query")).not.toBe("rate(cpu[5m])");
+ });
+ it("skips per-query values that are absent", () => {
+ const sp = build({ queries: [{ stream: "cpu" }] });
+ expect(sp.has("query")).toBe(false);
+ });
+});
+
+describe("deepLinkParams · applyOverridesFromRegistry · panel (real)", () => {
+ it("applies a valid chart_type to data.type", () => {
+ const state = mkState();
+ applyOverridesFromRegistry(METRICS_PARAMS, { chart_type: "bar" }, state);
+ expect(state.data.type).toBe("bar");
+ });
+ it("ignores an invalid chart_type (keeps base)", () => {
+ const state = mkState();
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { chart_type: "not-a-chart" },
+ state,
+ );
+ expect(state.data.type).toBe("line");
+ });
+ it("maps query_type sql/promql", () => {
+ const s1 = mkState();
+ applyOverridesFromRegistry(METRICS_PARAMS, { query_type: "sql" }, s1);
+ expect(s1.data.queryType).toBe("sql");
+ const s2 = mkState();
+ applyOverridesFromRegistry(METRICS_PARAMS, { query_type: "anything" }, s2);
+ expect(s2.data.queryType).toBe("promql"); // non-sql -> promql
+ });
+});
+
+describe("deepLinkParams · applyOverridesFromRegistry · per-query literal index (real)", () => {
+ it("overrides queries[i] IN PLACE when the base has it (surgical)", () => {
+ const base = [
+ defaultMetricsQuery(),
+ defaultMetricsQuery(),
+ defaultMetricsQuery(),
+ ];
+ base[0].query = "q0";
+ base[1].query = "q1";
+ base[2].query = "q2";
+ const state = mkState(base);
+ const ref0 = state.data.queries[0];
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { "query.1": enc("OVERRIDDEN") },
+ state,
+ );
+ expect(state.data.queries).toHaveLength(3);
+ expect(state.data.queries[0]).toBe(ref0); // same ref, untouched
+ expect(state.data.queries[0].query).toBe("q0");
+ expect(state.data.queries[1].query).toBe("OVERRIDDEN");
+ expect(state.data.queries[1].customQuery).toBe(true);
+ expect(state.data.queries[2].query).toBe("q2");
+ });
+
+ it("appends a cloned default beyond the base length", () => {
+ const state = mkState([defaultMetricsQuery()]);
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { query: enc("A"), "query.1": enc("B") },
+ state,
+ { makeDefaultQuery: defaultMetricsQuery },
+ );
+ expect(state.data.queries).toHaveLength(2);
+ expect(state.data.queries[0].query).toBe("A");
+ expect(state.data.queries[1].query).toBe("B");
+ });
+
+ it("base64-decodes the query and marks it custom", () => {
+ const state = mkState();
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { query: enc("rate(cpu[5m])") },
+ state,
+ );
+ expect(state.data.queries[0].query).toBe("rate(cpu[5m])");
+ expect(state.data.queries[0].customQuery).toBe(true);
+ });
+
+ it("applies stream_name to fields.stream and forces stream_type=metrics", () => {
+ const state = mkState();
+ applyOverridesFromRegistry(METRICS_PARAMS, { stream_name: "cpu" }, state);
+ expect(state.data.queries[0].fields.stream).toBe("cpu");
+ expect(state.data.queries[0].fields.stream_type).toBe("metrics");
+ });
+
+ it("honors '.0 wins over bare' when applying index 0", () => {
+ const state = mkState();
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { query: enc("bare"), "query.0": enc("dot0") },
+ state,
+ );
+ expect(state.data.queries[0].query).toBe("dot0");
+ });
+
+ it("resets layout.currentQueryIndex to 0", () => {
+ const state = mkState();
+ applyOverridesFromRegistry(METRICS_PARAMS, { stream_name: "cpu" }, state);
+ expect(state.layout.currentQueryIndex).toBe(0);
+ });
+
+ it("invokes onIndexApplied once per addressed index", () => {
+ const calls: number[] = [];
+ const state = mkState([defaultMetricsQuery()]);
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { stream_name: "cpu", "stream_name.1": "mem" },
+ state,
+ {
+ makeDefaultQuery: defaultMetricsQuery,
+ onIndexApplied: (_slot, index) => calls.push(index),
+ },
+ );
+ expect(calls).toEqual([0, 1]);
+ });
+
+ it("leaves queries untouched when no per-query params are present", () => {
+ const state = mkState([defaultMetricsQuery()]);
+ state.data.queries[0].query = "keep";
+ applyOverridesFromRegistry(METRICS_PARAMS, { chart_type: "bar" }, state);
+ expect(state.data.queries).toHaveLength(1);
+ expect(state.data.queries[0].query).toBe("keep");
+ });
+
+ it("does not crash when state has no layout", () => {
+ const state: any = { data: { queries: [defaultMetricsQuery()] } };
+ expect(() =>
+ applyOverridesFromRegistry(METRICS_PARAMS, { stream_name: "cpu" }, state),
+ ).not.toThrow();
+ expect(state.data.queries[0].fields.stream).toBe("cpu");
+ });
+});
+
+describe("deepLinkParams · applyOverridesFromRegistry · compactIndices (real)", () => {
+ const opts = { makeDefaultQuery: defaultMetricsQuery, compactIndices: true };
+
+ it("compacts a leading gap: query.1 only -> one query at slot 0", () => {
+ const state = mkState([defaultMetricsQuery()]);
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { "query.1": enc("B") },
+ state,
+ opts,
+ );
+ expect(state.data.queries).toHaveLength(1);
+ expect(state.data.queries[0].query).toBe("B");
+ });
+
+ it("compacts a double gap: query.2 + query.5 -> slots 0,1", () => {
+ const state = mkState([defaultMetricsQuery()]);
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { "query.2": enc("X"), "query.5": enc("Y") },
+ state,
+ opts,
+ );
+ expect(state.data.queries).toHaveLength(2);
+ expect(state.data.queries[0].query).toBe("X");
+ expect(state.data.queries[1].query).toBe("Y");
+ });
+
+ it("keeps contiguous query.0 + query.1 as two queries", () => {
+ const state = mkState([defaultMetricsQuery()]);
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { "query.0": enc("A"), "query.1": enc("B") },
+ state,
+ opts,
+ );
+ expect(state.data.queries).toHaveLength(2);
+ expect(state.data.queries[0].query).toBe("A");
+ expect(state.data.queries[1].query).toBe("B");
+ });
+
+ it("does NOT compact when compactIndices is false (leaves an empty base slot)", () => {
+ const state = mkState([defaultMetricsQuery()]);
+ applyOverridesFromRegistry(METRICS_PARAMS, { "query.1": enc("B") }, state, {
+ makeDefaultQuery: defaultMetricsQuery,
+ });
+ expect(state.data.queries).toHaveLength(2);
+ expect(state.data.queries[0].query).toBe(""); // untouched base default
+ expect(state.data.queries[1].query).toBe("B");
+ });
+});
+
+describe("deepLinkParams · query honored verbatim (custom) vs builder mode", () => {
+ it("honors a provided query VERBATIM and marks it custom (no decomposition)", () => {
+ const state = mkState();
+ const raw = 'sum(rate(http_requests_total{job="api",code="500"}[5m]))';
+ applyOverridesFromRegistry(METRICS_PARAMS, { query: enc(raw) }, state);
+ expect(state.data.queries[0].query).toBe(raw); // exact text, untouched
+ expect(state.data.queries[0].customQuery).toBe(true);
+ });
+
+ it("keeps a stream_name-only slot in BUILDER mode (customQuery stays false)", () => {
+ const state = mkState(); // default slot: customQuery = false
+ applyOverridesFromRegistry(METRICS_PARAMS, { stream_name: "cpu" }, state);
+ expect(state.data.queries[0].fields.stream).toBe("cpu");
+ expect(state.data.queries[0].customQuery).toBe(false); // still builder
+ expect(state.data.queries[0].query).toBe(""); // no query seeded by overrides
+ });
+
+ it("stream_name + query together: the query wins and stays custom/verbatim", () => {
+ const state = mkState();
+ const raw = "rate(node_cpu_seconds_total[5m])";
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { stream_name: "cpu", query: enc(raw) },
+ state,
+ );
+ expect(state.data.queries[0].fields.stream).toBe("cpu");
+ expect(state.data.queries[0].query).toBe(raw); // authoritative, verbatim
+ expect(state.data.queries[0].customQuery).toBe(true); // custom, not decomposed
+ });
+
+ it("derives custom vs builder PER INDEX (q0 custom, q1 builder)", () => {
+ const state = mkState([defaultMetricsQuery()]);
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { query: enc("up"), "stream_name.1": "mem" },
+ state,
+ { makeDefaultQuery: defaultMetricsQuery },
+ );
+ expect(state.data.queries[0].customQuery).toBe(true); // has query -> custom
+ expect(state.data.queries[0].query).toBe("up");
+ expect(state.data.queries[1].customQuery).toBe(false); // stream only -> builder
+ expect(state.data.queries[1].fields.stream).toBe("mem");
+ expect(state.data.queries[1].query).toBe("");
+ });
+
+ it("there is no separate `custom` param — customQuery is derived from query presence", () => {
+ // a bare custom flag is meaningless; only the presence of `query` flips it
+ const state = mkState();
+ applyOverridesFromRegistry(
+ METRICS_PARAMS,
+ { custom: "false", query: enc("up") } as any,
+ state,
+ );
+ expect(state.data.queries[0].customQuery).toBe(true); // query presence wins
+ });
+});
diff --git a/web/src/utils/url/deepLinkParams.ts b/web/src/utils/url/deepLinkParams.ts
new file mode 100644
index 0000000000..1c8a58043a
--- /dev/null
+++ b/web/src/utils/url/deepLinkParams.ts
@@ -0,0 +1,174 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Page-agnostic deep-link param engine (registry-driven build/apply/auto-run gate).
+
+export type ParamScope = "panel" | "perQuery";
+
+export interface ParamApplyCtx {
+ index?: number;
+ panelData?: any;
+}
+
+export interface ParamDescriptor {
+ key: string;
+ // extra keys accepted on read, never emitted on build (e.g. legacy `stream`)
+ aliases?: string[];
+ scope: ParamScope; // per-query is indexed: bare ≡ .0, then .1, .2 …
+ decode?: (raw: string) => T;
+ encode?: (value: T) => string | null;
+ apply: (target: any, value: T, ctx: ParamApplyCtx) => void;
+ read?: (source: any, index?: number) => T | undefined | null;
+}
+
+export interface ApplyOptions {
+ getQueries?: (state: any) => any[] | undefined;
+ makeDefaultQuery?: () => any;
+ onIndexApplied?: (slot: any, index: number, state: any) => void;
+ // compact addressed indices to 0..N-1 (build-from-scratch); else honor literal index
+ compactIndices?: boolean;
+}
+
+const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+const keysOf = (d: ParamDescriptor): string[] => [d.key, ...(d.aliases ?? [])];
+
+const decodeValue = (d: ParamDescriptor, raw: any) =>
+ d.decode ? d.decode(String(raw)) : raw;
+
+const encodeValue = (d: ParamDescriptor, value: any): string | null =>
+ d.encode ? d.encode(value) : String(value);
+
+export const indexedKey = (key: string, i: number): string =>
+ i === 0 ? key : `${key}.${i}`;
+
+export const readIndexed = (
+ query: Record,
+ key: string,
+ i: number,
+): any => {
+ const dotted = query[`${key}.${i}`];
+ if (dotted !== undefined) return dotted; // explicit .i wins (incl. .0)
+ if (i === 0) return query[key]; // bare ≡ .0
+ return undefined;
+};
+
+export const parseQueryIndices = (
+ query: Record,
+ perQueryDescriptors: ParamDescriptor[],
+): number[] => {
+ const indices = new Set();
+ for (const d of perQueryDescriptors) {
+ for (const key of keysOf(d)) {
+ if (query[key] != null || query[`${key}.0`] != null) indices.add(0);
+ const re = new RegExp(`^${escapeRegExp(key)}\\.(\\d+)$`);
+ for (const k of Object.keys(query)) {
+ const m = k.match(re);
+ if (m) indices.add(parseInt(m[1], 10));
+ }
+ }
+ }
+ return Array.from(indices).sort((a, b) => a - b);
+};
+
+export const hasAnyDeepLinkParam = (
+ query: Record,
+ registry: ParamDescriptor[],
+): boolean =>
+ registry.some((d) =>
+ keysOf(d).some((key) => {
+ if (query[key] != null) return true;
+ if (d.scope === "panel") return false;
+ return Object.keys(query).some((k) => k.startsWith(`${key}.`));
+ }),
+ );
+
+export const buildUrlFromRegistry = (
+ url: URL,
+ registry: ParamDescriptor[],
+ intent: any,
+): URL => {
+ const p = url.searchParams;
+
+ for (const d of registry) {
+ if (d.scope !== "panel") continue;
+ const v = d.read ? d.read(intent) : intent?.[d.key];
+ if (v == null || v === "") continue;
+ const enc = encodeValue(d, v);
+ if (enc != null) p.set(d.key, enc);
+ }
+
+ const queries: any[] = intent?.queries ?? [];
+ queries.forEach((qIntent, i) => {
+ for (const d of registry) {
+ if (d.scope !== "perQuery") continue;
+ const v = d.read ? d.read(qIntent, i) : qIntent?.[d.key];
+ if (v == null || v === "") continue;
+ const enc = encodeValue(d, v);
+ if (enc != null) p.set(indexedKey(d.key, i), enc);
+ }
+ });
+
+ return url;
+};
+
+export const applyOverridesFromRegistry = (
+ registry: ParamDescriptor[],
+ query: Record,
+ state: any,
+ options: ApplyOptions = {},
+): void => {
+ const getQueries =
+ options.getQueries ?? ((s: any) => s?.data?.queries as any[] | undefined);
+ const makeDefaultQuery = options.makeDefaultQuery ?? (() => ({}));
+
+ const panelDescriptors = registry.filter((d) => d.scope === "panel");
+ const perQueryDescriptors = registry.filter((d) => d.scope === "perQuery");
+
+ for (const d of panelDescriptors) {
+ const key = keysOf(d).find((k) => query[k] != null); // first key/alias wins
+ if (key != null)
+ d.apply(state, decodeValue(d, query[key]), { panelData: state });
+ }
+
+ const queries = getQueries(state);
+ if (Array.isArray(queries)) {
+ const indices = parseQueryIndices(query, perQueryDescriptors);
+ const baseLen = queries.length; // capture before appends
+ indices.forEach((i, pos) => {
+ // compact (build-from-scratch) maps to 0..N-1; else honor the literal index
+ const target = options.compactIndices ? pos : i;
+ let slot: any;
+ if (target < baseLen && queries[target]) {
+ slot = queries[target];
+ } else {
+ slot = makeDefaultQuery();
+ queries.push(slot);
+ }
+ for (const d of perQueryDescriptors) {
+ let raw: any;
+ for (const key of keysOf(d)) {
+ raw = readIndexed(query, key, i);
+ if (raw !== undefined) break;
+ }
+ if (raw != null)
+ d.apply(slot, decodeValue(d, raw), { index: target, panelData: state });
+ }
+ options.onIndexApplied?.(slot, target, state);
+ });
+ }
+
+ if (state?.layout) state.layout.currentQueryIndex = 0;
+};
diff --git a/web/src/utils/zincutils.ts b/web/src/utils/zincutils.ts
index ff8b954ae2..7dddbb2ba9 100644
--- a/web/src/utils/zincutils.ts
+++ b/web/src/utils/zincutils.ts
@@ -204,6 +204,12 @@ export const b64DecodeUnicode = (str: string) => {
}
};
+// url-safe base64 decode that never throws (bad input -> fallback, default "")
+export const b64DecodeUnicodeSafe = (str: string, fallback = ""): string => {
+ if (!str) return fallback;
+ return b64DecodeUnicode(str) ?? fallback;
+};
+
/**
* Helper function to check if a string is base64 encoded
* Used to detect double-encoded VRL functions from v0.40 legacy data