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