feat: metrics page share url support (#12607)

Design At:
https://github.com/openobserve/designs/blob/main/metrics-page/share-url/version2
This commit is contained in:
Abhay Padamani
2026-06-12 18:06:21 +05:30
committed by GitHub
parent d996349aa4
commit ab7bdd5e31
13 changed files with 1934 additions and 33 deletions
@@ -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 <http://www.gnu.org/licenses/>.
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=<b64>&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<string, string> => {
const sp = new URL(url).searchParams;
const q: Record<string, string> = {};
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);
});
});
@@ -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 <http://www.gnu.org/licenses/>.
// 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<string, any>;
}
// 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<string, any>,
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";
},
});
};
+8 -1
View File
@@ -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<string, any> = {}) => {
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:
+133 -32
View File
@@ -52,6 +52,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="dashboard-icons"
data-test="metrics-auto-refresh"
/>
<ShareButton
v-if="!['html', 'markdown'].includes(dashboardPanelData.data.type)"
:url="metricsShareUrl"
variant="outline"
size="icon-toolbar"
data-test="metrics-share-btn"
class="dashboard-icons"
/>
<template
v-if="!['html', 'markdown'].includes(dashboardPanelData.data.type)"
>
@@ -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<string, any> = {
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<string, any>;
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,
};
},
});
@@ -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 <http://www.gnu.org/licenses/>.
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);
});
});
+82
View File
@@ -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 <http://www.gnu.org/licenses/>.
// 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<string, never>;
// URL params -> selectedDate (period wins, else from/to, else relative 15m).
export const queryParamsToSelectedDate = (
params: LocationQuery | Record<string, any>,
): 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);
@@ -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 <http://www.gnu.org/licenses/>.
// `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<string, string> = {};
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);
});
});
+84
View File
@@ -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 <http://www.gnu.org/licenses/>.
// 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<string> => {
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;
};
@@ -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 <http://www.gnu.org/licenses/>.
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");
});
});
@@ -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 <http://www.gnu.org/licenses/>.
// 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<string>([
"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,
},
];
+412
View File
@@ -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 <http://www.gnu.org/licenses/>.
// 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
});
});
+174
View File
@@ -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 <http://www.gnu.org/licenses/>.
// 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<T = any> {
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<string, any>,
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<string, any>,
perQueryDescriptors: ParamDescriptor[],
): number[] => {
const indices = new Set<number>();
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<string, any>,
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<string, any>,
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;
};
+6
View File
@@ -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