+
.
{
+ // Converting the empty pre-load buffer would publish an empty result to the header and curated tiles.
+ if (isAwaitingFirstLoad.value) return;
// Preserve the previously rendered chart during a reload. While loading,
// if the new data buffer has no rows yet but a chart is already rendered,
// skip conversion so non-streaming callers (the panelSchema deep watcher,
@@ -2176,6 +2190,7 @@ export default defineComponent({
errorDetail,
panelData,
noData,
+ isAwaitingFirstLoad,
chartRendererData,
metadata,
tableRendererRef,
diff --git a/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts b/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts
index 3fd0c1e608..d5ad5a0c7c 100644
--- a/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts
+++ b/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts
@@ -164,7 +164,8 @@ vi.mock("@/composables/dashboard/useCancelQuery", () => ({
}));
// Mock date utilities
-vi.mock("@/utils/date", () => ({
+vi.mock("@/utils/date", async (importOriginal) => ({
+ ...((await importOriginal()) as any),
parseDuration: vi.fn((duration: string) => {
if (duration === "30s") return 30;
if (duration === "1m") return 60;
@@ -184,6 +185,8 @@ vi.mock("lodash-es", () => ({
isEqual: vi.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)),
}));
+const pickerSetSavedDate = vi.fn();
+
describe("ViewPanel", () => {
let wrapper: VueWrapper;
let mockGetDashboard: any;
@@ -202,7 +205,7 @@ describe("ViewPanel", () => {
searchType: "logs",
};
- const createWrapper = (props = {}) => {
+ const createWrapper = (props = {}, extraStubs = {}) => {
return mount(ViewPanel, {
props: {
...defaultProps,
@@ -215,9 +218,13 @@ describe("ViewPanel", () => {
},
stubs: {
DateTimePickerDashboard: {
+ name: "DateTimePickerDashboard",
template: '
',
+ props: ["modelValue", "autoApplyDashboard", "disable"],
+ emits: ["update:modelValue", "show", "hide", "timezone-change"],
methods: {
refresh: vi.fn(),
+ setSavedDate: pickerSetSavedDate,
getConsumableDateTime: () => ({
startTime: "2023-01-01T00:00:00Z",
endTime: "2023-01-02T00:00:00Z",
@@ -349,6 +356,7 @@ describe("ViewPanel", () => {
props: ["panelData", "open"],
emits: ["update:open"],
},
+ ...extraStubs,
},
},
});
@@ -1034,21 +1042,147 @@ describe("ViewPanel", () => {
it("should refresh data when not disabled", () => {
wrapper = createWrapper();
wrapper.vm.disable = false;
+ const appliedBefore = wrapper.vm.selectedDate;
- const refreshSpy = vi.spyOn(wrapper.vm.dateTimePickerRef, "refresh");
wrapper.vm.refreshData();
- expect(refreshSpy).toHaveBeenCalled();
+ expect(wrapper.vm.selectedDate).not.toBe(appliedBefore);
+ expect(wrapper.vm.selectedDate).toEqual(appliedBefore);
});
it("should not refresh data when disabled", () => {
wrapper = createWrapper();
wrapper.vm.disable = true;
+ const appliedBefore = wrapper.vm.selectedDate;
- const refreshSpy = vi.spyOn(wrapper.vm.dateTimePickerRef, "refresh");
wrapper.vm.refreshData();
- expect(refreshSpy).not.toHaveBeenCalled();
+ expect(wrapper.vm.selectedDate).toBe(appliedBefore);
+ });
+ });
+
+ describe("time range without Apply", () => {
+ const MINUTE = 60 * 1_000_000;
+ const relative = (period: string) => ({
+ valueType: "relative",
+ relativeTimePeriod: period,
+ startTime: 1,
+ endTime: 2,
+ });
+ const setAutoRun = (on: boolean) => {
+ mockStore.state.zoConfig = { ...mockStore.state.zoConfig, auto_query_enabled: on };
+ localStorage.setItem("oo_toggle_auto_run", String(on));
+ };
+ const picker = () => wrapper.findComponent({ name: "DateTimePickerDashboard" });
+ const pick = async (value: any) => {
+ picker().vm.$emit("update:modelValue", value);
+ await flushPromises();
+ };
+ const refreshTooltip = () =>
+ wrapper
+ .findComponent('[data-test="dashboard-viewpanel-refresh-data-btn"]')
+ .findComponent({ name: "OTooltip" })
+ .props("content");
+ const chartSpan = () => {
+ const time = wrapper.vm.dashboardPanelData.meta.dateTime;
+ return time.end_time.getTime() - time.start_time.getTime();
+ };
+ const mountOnRange = async () => {
+ wrapper = createWrapper({ selectedDateForViewPanel: relative("15m") });
+ await flushPromises();
+ await pick(relative("15m"));
+ };
+
+ afterEach(() => {
+ localStorage.removeItem("oo_toggle_auto_run");
+ mockStore.state.zoConfig = { ...mockStore.state.zoConfig, auto_query_enabled: false };
+ });
+
+ it("uses the picker without an Apply button", async () => {
+ await mountOnRange();
+
+ expect(picker().props("autoApplyDashboard")).toBe(true);
+ expect(chartSpan()).toBe(15 * MINUTE);
+ });
+
+ it("keeps a picked range waiting with Auto Run off and turns Refresh yellow", async () => {
+ setAutoRun(false);
+ await mountOnRange();
+
+ await pick(relative("1h"));
+
+ expect(picker().props("modelValue").relativeTimePeriod).toBe("1h");
+ expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("15m");
+ expect(chartSpan()).toBe(15 * MINUTE);
+ expect(wrapper.vm.showApplyHint).toBe(true);
+ expect(refreshTooltip()).toBe("dashboard.refreshToApplyTimeRange");
+ });
+
+ it("applies the waiting range on Refresh", async () => {
+ setAutoRun(false);
+ await mountOnRange();
+ await pick(relative("1h"));
+
+ wrapper.vm.disable = false;
+ wrapper.vm.refreshData();
+ await flushPromises();
+
+ expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h");
+ expect(chartSpan()).toBe(60 * MINUTE);
+ expect(wrapper.vm.showApplyHint).toBe(false);
+ });
+
+ it("names both when variables are waiting too", async () => {
+ setAutoRun(false);
+ await mountOnRange();
+ wrapper.vm.isVariablesChanged = true;
+
+ await pick(relative("1h"));
+
+ expect(refreshTooltip()).toBe("dashboard.refreshToApplyTimeRangeAndVariables");
+ });
+
+ it("loads variables on the applied range when the picker closes", async () => {
+ setAutoRun(false);
+ await mountOnRange();
+ picker().vm.$emit("show");
+ await pick(relative("1h"));
+
+ picker().vm.$emit("hide");
+ await flushPromises();
+
+ const time = wrapper.vm.dateTimeForVariables;
+ expect(time.end_time.getTime() - time.start_time.getTime()).toBe(15 * MINUTE);
+ });
+
+ it("puts the picker back on the applied range when it closes on a broken range", async () => {
+ setAutoRun(false);
+ await mountOnRange();
+ pickerSetSavedDate.mockClear();
+ picker().vm.$emit("show");
+ await pick({ valueType: "absolute", startTime: 300 * MINUTE, endTime: 200 * MINUTE });
+
+ picker().vm.$emit("hide");
+ await flushPromises();
+
+ expect(pickerSetSavedDate).toHaveBeenCalledWith({
+ type: "relative",
+ relativeTimePeriod: "15m",
+ });
+ expect(picker().props("modelValue").relativeTimePeriod).toBe("15m");
+ expect(wrapper.vm.showApplyHint).toBe(false);
+ expect(chartSpan()).toBe(15 * MINUTE);
+ });
+
+ it("applies a preset at once with Auto Run on", async () => {
+ setAutoRun(true);
+ await mountOnRange();
+
+ await pick(relative("1h"));
+
+ expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h");
+ expect(chartSpan()).toBe(60 * MINUTE);
+ expect(wrapper.vm.showApplyHint).toBe(false);
});
});
@@ -1114,6 +1248,53 @@ describe("ViewPanel", () => {
expect(wrapper.vm.isAutoRunOn).toBe(true);
expect(wrapper.vm.showApplyHint).toBe(false);
});
+
+ it("uses the dashboard Auto Run copy for the Refresh tooltip while Auto Run is on", async () => {
+ setAutoQuery(true);
+ wrapper = createWrapper();
+ await flushPromises();
+
+ const tooltip = wrapper
+ .findComponent('[data-test="dashboard-viewpanel-refresh-data-btn"]')
+ .findComponent({ name: "OTooltip" })
+ .props("content");
+ expect(tooltip).toBe("dashboard.viewDashboard.autoRunDescription");
+ });
+
+ it("describes Auto Run in its menu item like the dashboard toolbar, in both states", async () => {
+ setAutoQuery(true);
+ wrapper = createWrapper(
+ {},
+ {
+ ODropdown: { template: '' },
+ ODropdownItem: { template: '' },
+ },
+ );
+ await flushPromises();
+ const toggle = () => wrapper.find('[data-test="dashboard-viewpanel-auto-run-toggle-btn"]');
+ const lines = () =>
+ toggle()
+ .findAll("div")
+ .map((line: any) => line.text());
+
+ expect(lines()).toEqual([
+ "search.turnOffLiveMode",
+ "dashboard.viewDashboard.autoRunDescription",
+ "dashboard.viewDashboard.autoRunScope",
+ ]);
+ expect(toggle().find('[data-test="dashboard-viewpanel-auto-run-scope"]').classes()).toEqual(
+ expect.arrayContaining(["text-text-secondary", "text-xs"]),
+ );
+
+ wrapper.vm.toggleAutoRun();
+ await flushPromises();
+
+ expect(lines()).toEqual([
+ "search.turnOnLiveMode",
+ "dashboard.viewDashboard.autoRunDescription",
+ "dashboard.viewDashboard.autoRunScope",
+ ]);
+ });
});
describe("Component Lifecycle", () => {
diff --git a/web/src/components/dashboards/viewPanel/ViewPanel.vue b/web/src/components/dashboards/viewPanel/ViewPanel.vue
index 8e4b094f1f..1ec336a112 100644
--- a/web/src/components/dashboards/viewPanel/ViewPanel.vue
+++ b/web/src/components/dashboards/viewPanel/ViewPanel.vue
@@ -43,12 +43,16 @@
/>
-
+
@@ -125,7 +121,13 @@
{{ isAutoRunOn ? t("search.turnOffLiveMode") : t("search.turnOnLiveMode") }}
- {{ t("search.liveModeTooltip") }}
+ {{ t("dashboard.viewDashboard.autoRunDescription") }}
+
+
+ {{ t("dashboard.viewDashboard.autoRunScope") }}
@@ -262,11 +264,13 @@ import { isEqual } from "lodash-es";
import { processQueryMetadataErrors } from "@/utils/zincutils";
import { useVariablesManager } from "@/composables/dashboard/useVariablesManager";
import { useAutoRunToggle } from "@/composables/dashboard/useAutoRunToggle";
+import { useDashboardTimeGate } from "@/composables/dashboard/useDashboardTimeGate";
import {
DASHBOARD_FRESH_PANEL_TIME,
freshTimeFromAppliedRange,
} from "@/composables/dashboard/dashboardFreshPanelTime";
import { panelIdToBeRefreshed } from "@/utils/dashboard/convertCustomChartData";
+import { pickerValueToTimeObj, toPickerSavedDate } from "@/utils/dashboard/panelTimeUtils";
import OButton from "@/lib/core/Button/OButton.vue";
import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
@@ -363,7 +367,7 @@ export default defineComponent({
dashboardPanelDataPageKey,
t,
);
- // default selected date will be absolute time
+ // The applied range the chart loads with; the picker's own value is `timeGate.picked`.
const selectedDate: any = ref(props.selectedDateForViewPanel);
const dateTimePickerRef: any = ref(null);
const errorData: any = reactive({
@@ -618,14 +622,36 @@ export default defineComponent({
});
chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data));
- dateTimePickerRef.value.refresh();
+ timeGate.refresh();
Object.assign(currentVariablesDataRef, JSON.parse(JSON.stringify(variablesData)));
isVariablesChanged.value = false;
}
};
const { isAutoRunAvailable, isAutoRunOn, toggleAutoRun } = useAutoRunToggle();
- const showApplyHint = computed(() => isVariablesChanged.value && !isAutoRunOn.value);
+
+ const timeGate = useDashboardTimeGate({
+ applied: selectedDate,
+ isAutoRunOn: () => isAutoRunOn.value,
+ showInPicker: (value) =>
+ dateTimePickerRef.value?.setSavedDate(toPickerSavedDate(value, store.state.timezone)),
+ });
+
+ const showApplyHint = computed(
+ () => (isVariablesChanged.value && !isAutoRunOn.value) || timeGate.needsRefresh.value,
+ );
+
+ const refreshTooltip = computed(() => {
+ if (isAutoRunOn.value) return t("dashboard.viewDashboard.autoRunDescription");
+ if (timeGate.needsRefresh.value) {
+ return isVariablesChanged.value
+ ? t("dashboard.refreshToApplyTimeRangeAndVariables")
+ : t("dashboard.refreshToApplyTimeRange");
+ }
+ return isVariablesChanged.value
+ ? t("dashboard.refreshToApplyVariables")
+ : t("common.refresh");
+ });
watch(isAutoRunOn, (on) => variablesManager.setLiveMode(on), { immediate: true });
@@ -731,9 +757,9 @@ export default defineComponent({
watch(selectedDate, () => {
updateDateTime();
- // CRITICAL: When date time changes (user clicked Apply), also commit any pending variable changes
+ // CRITICAL: When the applied date time changes, also commit any pending variable changes
// This ensures that if user changed both variables and date time,
- // both changes are applied to the chart when Apply is clicked
+ // both changes are applied to the chart together
Object.assign(currentVariablesDataRef, JSON.parse(JSON.stringify(variablesData)));
isVariablesChanged.value = false;
@@ -744,33 +770,30 @@ export default defineComponent({
end_time: Date;
} | null>(null);
+ // Variables load with the applied range; a range still waiting must not reach them.
const setTimeForVariables = () => {
- const date = dateTimePickerRef.value?.getConsumableDateTime();
- const startTime = new Date(date.startTime);
- const endTime = new Date(date.endTime);
+ const appliedTime = pickerValueToTimeObj(selectedDate.value);
+ if (appliedTime) dateTimeForVariables.value = appliedTime;
+ };
- // Update the variables time object for query_values variables
- // This allows variables to load with the new time range
- // NOTE: This does NOT commit variables to the chart - only refreshData() does that
- dateTimeForVariables.value = {
- start_time: startTime,
- end_time: endTime,
- };
+ const onTimePickerHide = () => {
+ timeGate.onHide();
+ setTimeForVariables();
};
const updateDateTime = () => {
+ const appliedTime = pickerValueToTimeObj(selectedDate.value);
+ if (!appliedTime) return;
+
// CRITICAL: Clear panelIdToBeRefreshed to ensure panel refreshes
// In view panel mode, when time changes, this panel should always refresh
panelIdToBeRefreshed.value = null;
- dashboardPanelData.meta.dateTime = {
- start_time: new Date(selectedDate.value.startTime),
- end_time: new Date(selectedDate.value.endTime),
- };
+ dashboardPanelData.meta.dateTime = appliedTime;
dateTimeForVariables.value = {
- start_time: new Date(selectedDate.value.startTime),
- end_time: new Date(selectedDate.value.endTime),
+ start_time: new Date(appliedTime.start_time),
+ end_time: new Date(appliedTime.end_time),
};
};
const goBack = () => {
@@ -948,6 +971,10 @@ export default defineComponent({
currentVariablesDataRef,
isVariablesChanged,
showApplyHint,
+ refreshTooltip,
+ timeGate,
+ pickedDate: timeGate.picked,
+ onTimePickerHide,
isAutoRunAvailable,
isAutoRunOn,
toggleAutoRun,
diff --git a/web/src/composables/dashboard/dashboardFreshOnOpen.ts b/web/src/composables/dashboard/dashboardFreshOnOpen.ts
new file mode 100644
index 0000000000..c6f7f74d2e
--- /dev/null
+++ b/web/src/composables/dashboard/dashboardFreshOnOpen.ts
@@ -0,0 +1,50 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see
.
+
+import type { InjectionKey } from "vue";
+
+export interface DashboardFreshOnOpen {
+ /** Whether the panel's effective time range is relative; read when the panel loads, not live. */
+ isRelativeRange: (panel: any) => boolean;
+ /** Whether a panel restored from cache re-queries; read when the panel loads, not live. */
+ appliesTo: (panel: any) => boolean;
+ wasRefreshed: (key: string) => boolean;
+ markRefreshed: (key: string) => void;
+ reset: () => void;
+}
+
+// Dashboard-only: RUM AppPerformance also provides "dashboardAutoRun", so that key cannot gate this.
+export const DASHBOARD_FRESH_ON_OPEN: InjectionKey
=
+ Symbol("dashboardFreshOnOpen");
+
+export const createDashboardFreshOnOpen = ({
+ isAutoRunOn,
+ isRelativeRange,
+}: {
+ isAutoRunOn: () => boolean;
+ isRelativeRange: (panel: any) => boolean;
+}): DashboardFreshOnOpen => {
+ // Lives as long as one dashboard visit, so a revisited tab restores its fresh entry without a query.
+ const refreshed = new Set();
+ return {
+ isRelativeRange,
+ appliesTo: (panel) => isAutoRunOn() && isRelativeRange(panel),
+ wasRefreshed: (key) => refreshed.has(key),
+ markRefreshed: (key) => {
+ refreshed.add(key);
+ },
+ reset: () => refreshed.clear(),
+ };
+};
diff --git a/web/src/composables/dashboard/useDashboardTimeGate.spec.ts b/web/src/composables/dashboard/useDashboardTimeGate.spec.ts
new file mode 100644
index 0000000000..c028adc31a
--- /dev/null
+++ b/web/src/composables/dashboard/useDashboardTimeGate.spec.ts
@@ -0,0 +1,529 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
+import { effectScope, nextTick, ref, type EffectScope, type Ref } from "vue";
+import { ABSOLUTE_APPLY_DEBOUNCE_MS, useDashboardTimeGate } from "./useDashboardTimeGate";
+
+const relative = (period: string) => ({
+ valueType: "relative",
+ relativeTimePeriod: period,
+ startTime: 1,
+ endTime: 2,
+});
+
+const absolute = (startTime: number, endTime: number) => ({
+ valueType: "absolute",
+ relativeTimePeriod: null,
+ startTime,
+ endTime,
+});
+
+const START = relative("15m");
+
+describe("useDashboardTimeGate", () => {
+ let scope: EffectScope;
+ let autoRun: Ref;
+ let onAutoApply: Mock<(value: unknown) => void>;
+
+ const createGate = (applied = ref(null)) => {
+ const gate = scope.run(() =>
+ useDashboardTimeGate({ applied, isAutoRunOn: () => autoRun.value === true, onAutoApply }),
+ )!;
+ gate.onPick(START);
+ return gate;
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ scope = effectScope();
+ autoRun = ref(true);
+ onAutoApply = vi.fn();
+ });
+
+ afterEach(() => {
+ scope.stop();
+ vi.useRealTimers();
+ });
+
+ describe("starting range", () => {
+ it("takes the first range a picker reports as the applied one, with Auto Run off too", () => {
+ autoRun.value = false;
+ const gate = createGate();
+
+ expect(gate.applied.value).toEqual(START);
+ expect(gate.picked.value).toEqual(START);
+ expect(gate.isWaiting.value).toBe(false);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("writes the starting range into the applied ref it was given", () => {
+ const applied = ref({ valueType: "relative", relativeTimePeriod: "15m" });
+ createGate(applied);
+
+ expect(applied.value).toEqual(START);
+ });
+ });
+
+ describe("Auto Run on", () => {
+ it("applies a relative preset at once", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(relative("1h"));
+
+ expect(gate.applied.value).toEqual(relative("1h"));
+ expect(gate.isWaiting.value).toBe(false);
+ expect(onAutoApply).toHaveBeenCalledTimes(1);
+ expect(onAutoApply).toHaveBeenCalledWith(relative("1h"));
+ });
+
+ it("holds an absolute edit made in the open picker for the Logs pause", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS - 1);
+
+ expect(gate.applied.value).toEqual(START);
+ expect(gate.isWaiting.value).toBe(true);
+
+ vi.advanceTimersByTime(1);
+
+ expect(gate.applied.value).toEqual(absolute(100, 200));
+ expect(onAutoApply).toHaveBeenCalledTimes(1);
+ });
+
+ it("restarts the pause on every further edit and applies only the last one", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ vi.advanceTimersByTime(2000);
+ gate.onPick(absolute(100, 300));
+ vi.advanceTimersByTime(2000);
+
+ expect(gate.applied.value).toEqual(START);
+
+ vi.advanceTimersByTime(500);
+
+ expect(gate.applied.value).toEqual(absolute(100, 300));
+ expect(onAutoApply).toHaveBeenCalledTimes(1);
+ });
+
+ it("applies a held absolute edit at once when the picker closes", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ gate.onHide();
+
+ expect(gate.applied.value).toEqual(absolute(100, 200));
+
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+ expect(onAutoApply).toHaveBeenCalledTimes(1);
+ });
+
+ it("applies an absolute range at once while the picker is closed, as the arrows produce", () => {
+ const gate = createGate();
+
+ gate.onPick(absolute(100, 200));
+
+ expect(gate.applied.value).toEqual(absolute(100, 200));
+ });
+
+ it("applies a held range at once on flush, as a time zone change asks", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ gate.flush();
+
+ expect(gate.applied.value).toEqual(absolute(100, 200));
+ });
+
+ it("does nothing on close or flush when no edit is held", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onHide();
+ gate.flush();
+
+ expect(gate.applied.value).toEqual(START);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("drops the held edit when a preset is picked before the pause ends", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ gate.onPick(relative("1h"));
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(gate.applied.value).toEqual(relative("1h"));
+ expect(onAutoApply).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe.each([
+ ["off", false],
+ ["unavailable", undefined],
+ ])("Auto Run %s", (_label, state) => {
+ beforeEach(() => {
+ autoRun.value = state;
+ });
+
+ it("keeps a preset picked and waiting until it is applied", () => {
+ const gate = createGate();
+
+ gate.onPick(relative("1h"));
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS * 2);
+
+ expect(gate.applied.value).toEqual(START);
+ expect(gate.picked.value).toEqual(relative("1h"));
+ expect(gate.isWaiting.value).toBe(true);
+ expect(gate.needsRefresh.value).toBe(true);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("keeps an absolute edit waiting through the pause and through closing the picker", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+ gate.onHide();
+ gate.flush();
+
+ expect(gate.applied.value).toEqual(START);
+ expect(gate.needsRefresh.value).toBe(true);
+ });
+
+ it("applies the waiting range on apply and tells the caller it changed", () => {
+ const gate = createGate();
+ gate.onPick(relative("1h"));
+
+ expect(gate.apply()).toBe(true);
+
+ expect(gate.applied.value).toEqual(relative("1h"));
+ expect(gate.isWaiting.value).toBe(false);
+ expect(gate.apply()).toBe(false);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("applies the waiting range on refresh", () => {
+ const gate = createGate();
+ gate.onPick(relative("1h"));
+
+ gate.refresh();
+
+ expect(gate.applied.value).toEqual(relative("1h"));
+ });
+ });
+
+ describe("refresh", () => {
+ it("re-issues the applied range as a new object when nothing waits", () => {
+ const gate = createGate();
+ const before = gate.applied.value;
+
+ gate.refresh();
+
+ expect(gate.applied.value).toEqual(before);
+ expect(gate.applied.value).not.toBe(before);
+ });
+ });
+
+ describe("a broken range", () => {
+ it.each([
+ ["an empty start", { valueType: "absolute", startTime: NaN, endTime: 200 }],
+ ["an empty end", { valueType: "absolute", startTime: 100, endTime: NaN }],
+ ["a start after its end", absolute(300, 200)],
+ ["a start equal to its end", absolute(200, 200)],
+ ["a relative range without a period", { valueType: "relative", relativeTimePeriod: "m" }],
+ ])("never applies and never waits: %s", (_label, broken) => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(broken);
+ gate.onHide();
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(gate.isWaiting.value).toBe(false);
+ expect(gate.needsRefresh.value).toBe(false);
+ expect(gate.apply()).toBe(false);
+ expect(gate.applied.value).toEqual(START);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("cancels a held edit when the next edit breaks the range", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ gate.onPick(absolute(300, 200));
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(gate.applied.value).toEqual(START);
+ });
+
+ it("does not wait while Auto Run is off", () => {
+ autoRun.value = false;
+ const gate = createGate();
+
+ gate.onPick(absolute(300, 200));
+
+ expect(gate.needsRefresh.value).toBe(false);
+ });
+ });
+
+ describe("picking the applied range again", () => {
+ it("clears waiting without applying anything, when Auto Run is off", () => {
+ autoRun.value = false;
+ const gate = createGate();
+ const before = gate.applied.value;
+
+ gate.onPick(relative("1h"));
+ gate.onPick(relative("15m"));
+
+ expect(gate.isWaiting.value).toBe(false);
+ expect(gate.apply()).toBe(false);
+ expect(gate.applied.value).toBe(before);
+ });
+
+ it("treats a relative range as its period, whatever window it resolved to", () => {
+ autoRun.value = false;
+ const gate = createGate();
+
+ gate.onPick({ ...relative("15m"), startTime: 900, endTime: 1800 });
+
+ expect(gate.isWaiting.value).toBe(false);
+ });
+
+ it("cancels a held edit when the fields are edited back to the applied range", () => {
+ const gate = createGate();
+ gate.onPick(absolute(100, 200));
+ onAutoApply.mockClear();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 300));
+ gate.onPick(absolute(100, 200));
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(gate.applied.value).toEqual(absolute(100, 200));
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("compares an applied range read from the URL as numbers", () => {
+ autoRun.value = false;
+ const applied = ref(null);
+ const gate = createGate(applied);
+ gate.onPick(absolute(100, 200));
+ gate.apply();
+
+ applied.value = { valueType: "absolute", startTime: "100", endTime: "200" };
+
+ expect(gate.isWaiting.value).toBe(false);
+ });
+ });
+
+ describe("turning Auto Run on or off", () => {
+ it("applies a waiting range when Auto Run is turned on", async () => {
+ autoRun.value = false;
+ const gate = createGate();
+ gate.onPick(relative("1h"));
+
+ autoRun.value = true;
+ await nextTick();
+
+ expect(gate.applied.value).toEqual(relative("1h"));
+ expect(gate.isWaiting.value).toBe(false);
+ expect(onAutoApply).toHaveBeenCalledTimes(1);
+ });
+
+ it("applies nothing when Auto Run is turned on with no range waiting", async () => {
+ autoRun.value = false;
+ const gate = createGate();
+ const before = gate.applied.value;
+
+ autoRun.value = true;
+ await nextTick();
+
+ expect(gate.applied.value).toBe(before);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("keeps a held edit waiting when Auto Run is turned off before the pause ends", async () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ autoRun.value = false;
+ await nextTick();
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(gate.applied.value).toEqual(START);
+ expect(gate.needsRefresh.value).toBe(true);
+ });
+
+ it("shows no refresh signal for a range Auto Run is still holding", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+
+ expect(gate.isWaiting.value).toBe(true);
+ expect(gate.needsRefresh.value).toBe(false);
+ });
+ });
+
+ it("keeps its own applied range when the caller passes none", () => {
+ autoRun.value = false;
+ const gate = scope.run(() => useDashboardTimeGate({ isAutoRunOn: () => false }))!;
+
+ gate.onPick(START);
+ gate.onPick(relative("1h"));
+
+ expect(gate.applied.value).toEqual(START);
+ expect(gate.apply()).toBe(true);
+ expect(gate.applied.value).toEqual(relative("1h"));
+ });
+
+ describe("closing the picker on a broken range", () => {
+ let showInPicker: ReturnType;
+
+ const createGateShowingRanges = () => {
+ showInPicker = vi.fn();
+ const gate = scope.run(() =>
+ useDashboardTimeGate({
+ isAutoRunOn: () => autoRun.value === true,
+ onAutoApply,
+ showInPicker,
+ }),
+ )!;
+ gate.onPick(START);
+ return gate;
+ };
+
+ it.each([
+ ["on", true],
+ ["off", false],
+ ])("puts the picker back on the applied range with Auto Run %s", (_label, isOn) => {
+ autoRun.value = isOn;
+ const gate = createGateShowingRanges();
+
+ gate.onShow();
+ gate.onPick(absolute(300 * 60, 200 * 60));
+ gate.onHide();
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(showInPicker).toHaveBeenCalledTimes(1);
+ expect(showInPicker).toHaveBeenCalledWith(START);
+ expect(gate.picked.value).toEqual(START);
+ expect(gate.applied.value).toEqual(START);
+ expect(gate.isWaiting.value).toBe(false);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("also resets an emptied field", () => {
+ const gate = createGateShowingRanges();
+
+ gate.onShow();
+ gate.onPick({ valueType: "absolute", startTime: NaN, endTime: 200 });
+ gate.onHide();
+
+ expect(showInPicker).toHaveBeenCalledWith(START);
+ expect(gate.picked.value).toEqual(START);
+ });
+
+ it("keeps an invalid range while the picker stays open", () => {
+ const gate = createGateShowingRanges();
+
+ gate.onShow();
+ gate.onPick(absolute(300, 200));
+
+ expect(showInPicker).not.toHaveBeenCalled();
+ expect(gate.picked.value).toEqual(absolute(300, 200));
+ });
+
+ it("drops a held edit that was broken before the picker closed", () => {
+ const gate = createGateShowingRanges();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ gate.onPick(absolute(300, 200));
+ gate.onHide();
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(gate.applied.value).toEqual(START);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+
+ it("leaves the picker alone when it closes on a valid range", () => {
+ autoRun.value = false;
+ const gate = createGateShowingRanges();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ gate.onHide();
+
+ expect(showInPicker).not.toHaveBeenCalled();
+ expect(gate.needsRefresh.value).toBe(true);
+ });
+ });
+
+ describe("an applied range set from outside the picker", () => {
+ it("replaces a waiting range on adopt, so nothing waits and Refresh applies nothing old", () => {
+ autoRun.value = false;
+ const applied = ref(null);
+ const gate = createGate(applied);
+ gate.onPick(relative("1h"));
+
+ applied.value = relative("6h");
+ gate.adopt();
+
+ expect(gate.picked.value).toEqual(relative("6h"));
+ expect(gate.isWaiting.value).toBe(false);
+ expect(gate.apply()).toBe(false);
+ expect(applied.value).toEqual(relative("6h"));
+ });
+
+ it("drops a held absolute edit on adopt", () => {
+ const applied = ref(null);
+ const gate = createGate(applied);
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+
+ applied.value = relative("6h");
+ gate.adopt();
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(applied.value).toEqual(relative("6h"));
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+ });
+
+ it("drops a held edit when its scope is disposed", () => {
+ const gate = createGate();
+
+ gate.onShow();
+ gate.onPick(absolute(100, 200));
+ scope.stop();
+ vi.advanceTimersByTime(ABSOLUTE_APPLY_DEBOUNCE_MS);
+
+ expect(gate.applied.value).toEqual(START);
+ expect(onAutoApply).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/composables/dashboard/useDashboardTimeGate.ts b/web/src/composables/dashboard/useDashboardTimeGate.ts
new file mode 100644
index 0000000000..d1c2925025
--- /dev/null
+++ b/web/src/composables/dashboard/useDashboardTimeGate.ts
@@ -0,0 +1,135 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { computed, getCurrentScope, onScopeDispose, ref, watch, type Ref } from "vue";
+import { arePickerValuesEqual, isUsablePickerValue } from "@/utils/dashboard/panelTimeUtils";
+
+// Same pause Logs gives an absolute edit before it runs (debouncedAutoRunAbsolute).
+export const ABSOLUTE_APPLY_DEBOUNCE_MS = 2500;
+
+export interface DashboardTimeGateOptions {
+ /** The range panels load with; the gate creates its own when the caller keeps none. */
+ applied?: Ref;
+ isAutoRunOn: () => boolean;
+ /** Runs when Auto Run applies a range, for callers that cannot watch `applied`. */
+ onAutoApply?: (value: any) => void;
+ /** Moves the picker to a range, since a mounted picker reads its v-model only once. */
+ showInPicker?: (value: any) => void;
+}
+
+/** Keeps what a dashboard time picker shows apart from the range its panels load with. */
+export const useDashboardTimeGate = (options: DashboardTimeGateOptions) => {
+ const applied = options.applied ?? ref(null);
+ const picked = ref(null);
+
+ let hasStartingRange = false;
+ let isPickerOpen = false;
+ let pendingApply: ReturnType | null = null;
+
+ const isWaiting = computed(
+ () => isUsablePickerValue(picked.value) && !arePickerValuesEqual(picked.value, applied.value),
+ );
+ const needsRefresh = computed(() => isWaiting.value && !options.isAutoRunOn());
+
+ const cancelPendingApply = () => {
+ if (pendingApply === null) return;
+ clearTimeout(pendingApply);
+ pendingApply = null;
+ };
+
+ /** Promotes a waiting range and reports whether the applied range changed. */
+ const apply = () => {
+ cancelPendingApply();
+ if (!isWaiting.value) return false;
+ applied.value = { ...picked.value };
+ return true;
+ };
+
+ const autoApply = () => {
+ if (apply()) options.onAutoApply?.(applied.value);
+ };
+
+ /** Applies a waiting range, or re-issues the applied one so whatever watches it reloads. */
+ const refresh = () => {
+ if (!apply() && applied.value) applied.value = { ...applied.value };
+ };
+
+ const flush = () => {
+ if (pendingApply !== null) autoApply();
+ };
+
+ /** Drops any waiting range in favour of an applied range set from outside the picker. */
+ const adopt = () => {
+ cancelPendingApply();
+ picked.value = applied.value ? { ...applied.value } : null;
+ };
+
+ const onPick = (value: any) => {
+ picked.value = value;
+
+ // A picker reports its starting range when it mounts; that is the range already in force.
+ if (!hasStartingRange) {
+ hasStartingRange = true;
+ applied.value = { ...value };
+ return;
+ }
+
+ cancelPendingApply();
+ if (!isWaiting.value || !options.isAutoRunOn()) return;
+
+ const isAbsoluteEdit = value?.valueType !== "relative" && isPickerOpen;
+ if (isAbsoluteEdit) pendingApply = setTimeout(autoApply, ABSOLUTE_APPLY_DEBOUNCE_MS);
+ else autoApply();
+ };
+
+ const onShow = () => {
+ isPickerOpen = true;
+ };
+
+ const onHide = () => {
+ isPickerOpen = false;
+
+ // A broken range never applies, so closing on one puts the picker back on the range panels show.
+ if (picked.value && !isUsablePickerValue(picked.value) && applied.value) {
+ adopt();
+ options.showInPicker?.(applied.value);
+ return;
+ }
+ flush();
+ };
+
+ watch(options.isAutoRunOn, (isOn) => {
+ if (isOn) autoApply();
+ else cancelPendingApply();
+ });
+
+ if (getCurrentScope()) onScopeDispose(cancelPendingApply);
+
+ return {
+ picked,
+ applied,
+ isWaiting,
+ needsRefresh,
+ onPick,
+ onShow,
+ onHide,
+ flush,
+ apply,
+ refresh,
+ adopt,
+ };
+};
+
+export type DashboardTimeGate = ReturnType;
diff --git a/web/src/composables/dashboard/usePanelDataLoader.freshOnOpen.spec.ts b/web/src/composables/dashboard/usePanelDataLoader.freshOnOpen.spec.ts
new file mode 100644
index 0000000000..c66e7de5b3
--- /dev/null
+++ b/web/src/composables/dashboard/usePanelDataLoader.freshOnOpen.spec.ts
@@ -0,0 +1,777 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { defineComponent, h, provide, ref, watch } from "vue";
+import { mount, type VueWrapper } from "@vue/test-utils";
+import { usePanelDataLoader } from "./usePanelDataLoader";
+import {
+ createDashboardFreshOnOpen,
+ DASHBOARD_FRESH_ON_OPEN,
+ type DashboardFreshOnOpen,
+} from "./dashboardFreshOnOpen";
+import { isEffectivePanelTimeRelative } from "@/utils/dashboard/panelTimeUtils";
+
+// Real loader, executors and search handlers; only the cache, the stream transport and the network are fakes.
+const fakes = vi.hoisted(() => ({
+ cache: new Map(),
+ saves: [] as any[],
+ streams: [] as { payload: any; handlers: any }[],
+ cancelStream: null as any,
+ deleteRunningQueries: null as any,
+ traceCounter: 0,
+ streamLookup: null as any,
+}));
+
+vi.mock("./usePanelCache", () => ({
+ usePanelCache: (_folderId: string, _dashboardId: string, panelId: string) => ({
+ getPanelCache: async () => fakes.cache.get(panelId) ?? null,
+ savePanelCache: async (key: any, value: any, cacheTimeRange: any) => {
+ const entry = JSON.parse(JSON.stringify({ key, value, cacheTimeRange }));
+ fakes.saves.push(entry);
+ fakes.cache.set(panelId, entry);
+ },
+ }),
+}));
+
+vi.mock("../useStreamingSearch", () => ({
+ default: () => ({
+ fetchQueryDataWithHttpStream: (payload: any, handlers: any) => {
+ fakes.streams.push({ payload, handlers });
+ },
+ cancelStreamQueryBasedOnRequestId: (...args: any[]) => fakes.cancelStream(...args),
+ }),
+}));
+
+vi.mock("../../services/search", async (importOriginal) => {
+ const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
+ return overlayServiceMock(await importOriginal(), {
+ default: {
+ delete_running_queries: (...args: any[]) => fakes.deleteRunningQueries(...args),
+ },
+ });
+});
+
+vi.mock("./useAnnotations", () => ({
+ useAnnotations: () => ({ refreshAnnotations: async () => [] }),
+}));
+
+vi.mock("@/utils/query/sqlUtils", () => ({
+ addLabelsToSQlQuery: async (query: string) => query,
+ getStreamFromQuery: (...args: any[]) => fakes.streamLookup(...args),
+}));
+
+vi.mock("@/composables/useLogs/logsUtils", () => ({
+ default: () => ({ checkTimestampAlias: () => true }),
+}));
+
+vi.mock("@/utils/zincutils", async (importOriginal) => ({
+ ...(await importOriginal()),
+ generateTraceContext: () => ({
+ traceparent: "tp",
+ traceId: `trace-${++fakes.traceCounter}`,
+ }),
+}));
+
+vi.mock("vuex", () => ({
+ useStore: () => ({
+ state: {
+ selectedOrganization: { identifier: "org-1" },
+ zoConfig: {},
+ timezone: "UTC",
+ organizationData: { organizationSettings: { scrape_interval: 15 } },
+ },
+ }),
+}));
+
+const NOW = new Date("2026-10-12T10:00:00Z").getTime();
+const FIFTEEN_MINUTES = 15 * 60 * 1000;
+
+const windowEndingAt = (end: number, span = FIFTEEN_MINUTES) => ({
+ start_time: new Date(end - span),
+ end_time: new Date(end),
+});
+
+const sqlPanel = (config: Record = {}) => ({
+ id: "panel-1",
+ title: "Requests",
+ type: "line",
+ queryType: "sql",
+ config,
+ queries: [
+ {
+ query: "SELECT histogram(_timestamp) AS x, count(*) AS y FROM logs GROUP BY x",
+ fields: { stream_type: "logs", x: [] },
+ config: {},
+ },
+ ],
+});
+
+const RELATIVE = { valueType: "relative", relativeTimePeriod: "15m" };
+const ABSOLUTE = { valueType: "absolute", startTime: 1, endTime: 2 };
+
+// Mirrors ViewDashboard's provide: the Auto Run toggle plus the panel's effective range.
+const createVisit = (autoRun = { value: true }, globalTime: any = RELATIVE, query: any = {}) =>
+ createDashboardFreshOnOpen({
+ isAutoRunOn: () => autoRun.value,
+ isRelativeRange: (panel) => isEffectivePanelTimeRelative(panel, query, globalTime),
+ });
+
+const AUTO_RUN_OFF = { value: false };
+
+const AD_HOC_FILTER = [
+ {
+ type: "dynamic_filters",
+ name: "Dynamic filters",
+ value: [{ name: "service", operator: "=", value: "api" }],
+ isLoading: false,
+ isVariableLoadingPending: false,
+ },
+];
+
+const mounted: VueWrapper[] = [];
+
+const mountPanel = ({
+ visit,
+ panel = sqlPanel(),
+ time = windowEndingAt(NOW),
+ withoutCache = false,
+ variables = [],
+ injected = undefined,
+}: {
+ visit?: DashboardFreshOnOpen | null;
+ panel?: any;
+ time?: any;
+ withoutCache?: boolean;
+ variables?: any[];
+ injected?: any;
+} = {}) => {
+ let loader: any;
+ const selectedTimeObj = ref(time);
+ const Panel = defineComponent({
+ setup() {
+ const chartPanelRef = ref(null);
+ loader = usePanelDataLoader(
+ ref(panel),
+ selectedTimeObj,
+ ref({ isVariablesLoading: false, values: variables }),
+ chartPanelRef,
+ ref(false),
+ ref("dashboards"),
+ ref("dashboard-1"),
+ ref("folder-1"),
+ ref(null), // reportId
+ ref(null), // runId
+ ref(null), // tabId
+ ref(null), // tabName
+ ref(null), // searchResponse
+ ref(false), // is_ui_histogram
+ ref("Dashboard"),
+ ref("Folder"),
+ ref(withoutCache),
+ ref(null), // regionClusterParams
+ ref(false), // allowAnnotationsAPI
+ injected ? ref(injected) : undefined,
+ );
+ return () => h("div", { ref: chartPanelRef });
+ },
+ });
+ const Host = defineComponent({
+ setup() {
+ if (visit) provide(DASHBOARD_FRESH_ON_OPEN, visit);
+ return () => h(Panel);
+ },
+ });
+ const wrapper = mount(Host);
+ mounted.push(wrapper);
+ return { wrapper, loader, selectedTimeObj };
+};
+
+// Past the observer tick and the debounce; small steps let each awaited stage queue its next timer.
+const settle = async () => {
+ for (let step = 0; step < 20; step++) await vi.advanceTimersByTimeAsync(20);
+};
+
+const hits = (value: number) => [{ x: "2026-10-12T09:50:00", y: value }];
+
+const sendHits = (streamIndex: number, value: number) => {
+ const { payload, handlers } = fakes.streams[streamIndex];
+ handlers.data(payload, { type: "search_response", content: { results: { hits: hits(value) } } });
+};
+
+const endStream = (streamIndex: number) => {
+ const { payload, handlers } = fakes.streams[streamIndex];
+ handlers.data(payload, { type: "end" });
+};
+
+// A cold load in its own earlier visit leaves a complete entry in the cache.
+const seedCache = async (
+ value: number,
+ {
+ time = windowEndingAt(NOW - 10 * 60 * 1000),
+ visit = createVisit(),
+ variables = [] as any[],
+ } = {},
+) => {
+ vi.setSystemTime(time.end_time);
+ const { wrapper } = mountPanel({ visit, time, variables });
+ await settle();
+ sendHits(fakes.streams.length - 1, value);
+ endStream(fakes.streams.length - 1);
+ await settle();
+ wrapper.unmount();
+ vi.setSystemTime(NOW);
+ fakes.streams.length = 0;
+ fakes.saves.length = 0;
+};
+
+describe("usePanelDataLoader: fresh on open", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ fakes.cache.clear();
+ fakes.saves.length = 0;
+ fakes.streams.length = 0;
+ fakes.cancelStream = vi.fn();
+ fakes.deleteRunningQueries = vi.fn().mockResolvedValue({});
+ fakes.streamLookup = async () => "logs";
+ global.IntersectionObserver = vi.fn(function (this: any, callback: any) {
+ this.observe = () => callback([{ isIntersecting: true }]);
+ this.disconnect = vi.fn();
+ }) as any;
+ });
+
+ afterEach(() => {
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
+ vi.useRealTimers();
+ });
+
+ describe("gating", () => {
+ it("re-queries a restored panel when Auto Run is on and the range is relative", async () => {
+ await seedCache(10);
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(fakes.streams).toHaveLength(1);
+ expect(fakes.streams[0].payload.queryReq.query.end_time).toBe(NOW);
+ expect(fakes.streams[0].payload.clear_cache).toBe(false);
+ });
+
+ it("keeps today's restore-only open when Auto Run is off", async () => {
+ await seedCache(10);
+ const { loader } = mountPanel({ visit: createVisit({ value: false }) });
+ await settle();
+
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(fakes.streams).toHaveLength(0);
+ });
+
+ it("never re-queries outside a dashboard view", async () => {
+ await seedCache(10);
+ const { loader } = mountPanel({ visit: null });
+ await settle();
+
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(fakes.streams).toHaveLength(0);
+ });
+
+ it("leaves an absolute global range on its cached result", async () => {
+ const absolute = createVisit({ value: true }, ABSOLUTE);
+ await seedCache(10, { time: windowEndingAt(NOW), visit: absolute });
+ const { loader } = mountPanel({ visit: absolute });
+ await settle();
+
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(fakes.streams).toHaveLength(0);
+ });
+
+ it("follows the panel's own relative time over an absolute global range", async () => {
+ const panel = sqlPanel({
+ panel_time_enabled: true,
+ panel_time_range: { type: "relative", relativeTimePeriod: "15m" },
+ });
+ vi.setSystemTime(NOW - 60_000);
+ const first = mountPanel({ visit: createVisit({ value: true }, ABSOLUTE), panel });
+ await settle();
+ endStream(0);
+ await settle();
+ first.wrapper.unmount();
+ vi.setSystemTime(NOW);
+ fakes.streams.length = 0;
+
+ mountPanel({ visit: createVisit({ value: true }, ABSOLUTE), panel });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(1);
+ });
+
+ it("leaves a panel with its own absolute time alone under a relative global range", async () => {
+ const panel = sqlPanel({
+ panel_time_enabled: true,
+ panel_time_range: { type: "absolute", startTime: 1, endTime: 2 },
+ });
+ const first = mountPanel({ visit: createVisit(), panel });
+ await settle();
+ endStream(0);
+ await settle();
+ first.wrapper.unmount();
+ fakes.streams.length = 0;
+
+ mountPanel({ visit: createVisit(), panel });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(0);
+ });
+
+ it("loads normally, with nothing held, when there is no cache entry", async () => {
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(1);
+ sendHits(0, 7);
+ expect(loader.data.value).toEqual([hits(7)]);
+ });
+
+ it("reads through the server cache even when Refresh cache & reload was used earlier", async () => {
+ await seedCache(10);
+ mountPanel({ visit: createVisit(), withoutCache: true });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(1);
+ expect(fakes.streams[0].payload.clear_cache).toBe(false);
+ });
+
+ it("still skips the server cache for a normal load that asked for it", async () => {
+ mountPanel({ visit: createVisit(), withoutCache: true });
+ await settle();
+
+ expect(fakes.streams[0].payload.clear_cache).toBe(true);
+ });
+
+ it("reads the Auto Run gate when the panel loads, not live", async () => {
+ await seedCache(10);
+ const autoRun = { value: false };
+ mountPanel({ visit: createVisit(autoRun) });
+ await settle();
+ autoRun.value = true;
+ await settle();
+
+ expect(fakes.streams).toHaveLength(0);
+ });
+ });
+
+ describe("hold and swap", () => {
+ it("keeps the restored chart and its age, with loading on, until the refresh completes", async () => {
+ await seedCache(10);
+ const restoredAt = fakes.cache.get("panel-1").value.lastTriggeredAt;
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+
+ expect(loader.loading.value).toBe(true);
+ expect(loader.lastTriggeredAt.value).toBe(restoredAt);
+
+ sendHits(0, 42);
+ await settle();
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(loader.lastTriggeredAt.value).toBe(restoredAt);
+ expect(loader.isPartialData.value).toBe(false);
+ expect(fakes.saves).toHaveLength(0);
+
+ endStream(0);
+ expect(loader.loading.value).toBe(false);
+ expect(loader.data.value).toEqual([hits(42)]);
+ expect(loader.lastTriggeredAt.value).toBeGreaterThan(restoredAt);
+ await settle();
+ expect(fakes.cache.get("panel-1").value.data).toEqual([hits(42)]);
+ });
+
+ it("replaces the restored chart with the error when the refresh fails", async () => {
+ await seedCache(10);
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+
+ const { payload, handlers } = fakes.streams[0];
+ handlers.error(payload, { content: { message: "querier timed out", code: 504 } });
+
+ expect(loader.loading.value).toBe(false);
+ expect(loader.errorDetail.value.message).toBe("querier timed out");
+ expect(loader.data.value).not.toEqual([hits(10)]);
+ });
+
+ it("keeps the restored chart and its cache entry when the refresh is cancelled", async () => {
+ await seedCache(10);
+ const entryBefore = fakes.cache.get("panel-1");
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+ sendHits(0, 42);
+ await settle();
+
+ window.dispatchEvent(new Event("cancelQuery"));
+
+ expect(loader.loading.value).toBe(false);
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(loader.isPartialData.value).toBe(false);
+ expect(fakes.cancelStream).toHaveBeenCalled();
+ await settle();
+ expect(fakes.saves).toHaveLength(0);
+ expect(fakes.cache.get("panel-1")).toBe(entryBefore);
+ });
+
+ it("does not save the partial refresh over the cache when the panel unmounts mid-refresh", async () => {
+ await seedCache(10);
+ const entryBefore = fakes.cache.get("panel-1");
+ const { wrapper } = mountPanel({ visit: createVisit() });
+ await settle();
+ sendHits(0, 42);
+ await settle();
+
+ wrapper.unmount();
+ await settle();
+
+ expect(fakes.saves).toHaveLength(0);
+ expect(fakes.cache.get("panel-1")).toBe(entryBefore);
+ expect(fakes.cancelStream).toHaveBeenCalled();
+ expect(fakes.deleteRunningQueries).toHaveBeenCalled();
+ });
+
+ it("keeps holding when a newer run supersedes the refresh", async () => {
+ await seedCache(10);
+ const { loader, selectedTimeObj } = mountPanel({ visit: createVisit() });
+ await settle();
+
+ selectedTimeObj.value = windowEndingAt(NOW + 60_000);
+ await settle();
+ expect(fakes.streams).toHaveLength(2);
+ sendHits(1, 99);
+ await settle();
+ expect(loader.data.value).toEqual([hits(10)]);
+
+ endStream(1);
+ expect(loader.data.value).toEqual([hits(99)]);
+ expect(loader.loading.value).toBe(false);
+ });
+ });
+
+ describe("once per visit", () => {
+ it("restores a revisited tab's fresh entry without another query", async () => {
+ await seedCache(10);
+ const visit = createVisit();
+ const first = mountPanel({ visit });
+ await settle();
+ sendHits(0, 42);
+ endStream(0);
+ await settle();
+ first.wrapper.unmount();
+
+ const revisit = mountPanel({ visit });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(1);
+ expect(revisit.loader.data.value).toEqual([hits(42)]);
+ });
+
+ it("refreshes again on a new visit", async () => {
+ await seedCache(10);
+ const first = mountPanel({ visit: createVisit() });
+ await settle();
+ endStream(0);
+ await settle();
+ first.wrapper.unmount();
+
+ mountPanel({ visit: createVisit() });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(2);
+ });
+
+ it("refreshes a revisited tab whose earlier refresh never completed", async () => {
+ await seedCache(10);
+ const visit = createVisit();
+ const first = mountPanel({ visit });
+ await settle();
+ first.wrapper.unmount();
+
+ mountPanel({ visit });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(2);
+ });
+
+ it("refreshes a revisited tab whose cached result is for another window length", async () => {
+ await seedCache(10);
+ const visit = createVisit();
+ const first = mountPanel({ visit });
+ await settle();
+ endStream(0);
+ await settle();
+ first.wrapper.unmount();
+
+ mountPanel({ visit, time: windowEndingAt(NOW, 60 * 60 * 1000) });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(2);
+ });
+
+ it("forgets the visit's refreshed panels on reset", async () => {
+ await seedCache(10);
+ const visit = createVisit();
+ const first = mountPanel({ visit });
+ await settle();
+ endStream(0);
+ await settle();
+ first.wrapper.unmount();
+
+ visit.reset();
+ mountPanel({ visit });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(2);
+ });
+ });
+
+ describe("an interrupted run keeps the last complete entry", () => {
+ // A restored panel with Auto Run off, then a time change: a normal run, not a held refresh.
+ const startNormalRun = async (visit: DashboardFreshOnOpen | null, variables: any[] = []) => {
+ const panel = mountPanel({ visit, variables });
+ await settle();
+ expect(panel.loader.data.value).toEqual([hits(10)]);
+ panel.selectedTimeObj.value = windowEndingAt(NOW + 60_000);
+ await settle();
+ return panel;
+ };
+
+ it("when the view unmounts mid-run", async () => {
+ await seedCache(10, { visit: createVisit(AUTO_RUN_OFF) });
+ const entryBefore = fakes.cache.get("panel-1");
+ const { wrapper } = await startNormalRun(createVisit(AUTO_RUN_OFF));
+ sendHits(0, 42);
+
+ wrapper.unmount();
+ await settle();
+
+ expect(fakes.saves).toHaveLength(0);
+ expect(fakes.cache.get("panel-1")).toBe(entryBefore);
+ });
+
+ it("when a first load is interrupted, by writing nothing", async () => {
+ const { wrapper } = mountPanel({ visit: createVisit() });
+ await settle();
+ sendHits(0, 42);
+
+ wrapper.unmount();
+ await settle();
+
+ expect(fakes.cache.size).toBe(0);
+ });
+
+ it("when the run is cancelled", async () => {
+ await seedCache(10, { visit: createVisit(AUTO_RUN_OFF) });
+ const entryBefore = fakes.cache.get("panel-1");
+ await startNormalRun(createVisit(AUTO_RUN_OFF));
+ sendHits(0, 42);
+
+ window.dispatchEvent(new Event("cancelQuery"));
+ await settle();
+
+ expect(fakes.saves).toHaveLength(0);
+ expect(fakes.cache.get("panel-1")).toBe(entryBefore);
+ });
+
+ it("when the executor drops an aborted run before it streams", async () => {
+ const visit = createVisit(AUTO_RUN_OFF);
+ await seedCache(10, { visit, variables: AD_HOC_FILTER });
+ const entryBefore = fakes.cache.get("panel-1");
+ let finishLookup: (stream: string) => void = () => {};
+ fakes.streamLookup = () => new Promise((resolve) => (finishLookup = resolve));
+ const { wrapper } = await startNormalRun(visit, AD_HOC_FILTER);
+
+ wrapper.unmount();
+ finishLookup("logs");
+ await settle();
+
+ expect(fakes.streams).toHaveLength(0);
+ expect(fakes.saves).toHaveLength(0);
+ expect(fakes.cache.get("panel-1")).toBe(entryBefore);
+ });
+
+ it("when an editor run outside the dashboard view is interrupted", async () => {
+ await seedCache(10);
+ const entryBefore = fakes.cache.get("panel-1");
+ const { wrapper } = await startNormalRun(null);
+ sendHits(0, 42);
+
+ wrapper.unmount();
+ await settle();
+
+ expect(fakes.saves).toHaveLength(0);
+ expect(fakes.cache.get("panel-1")).toBe(entryBefore);
+ });
+ });
+
+ describe("absolute ranges restore only their own window", () => {
+ const absolute = () => createVisit({ value: true }, ABSOLUTE);
+ const earlier = () => windowEndingAt(NOW - 5 * 60_000);
+
+ it("restores the exact same window", async () => {
+ await seedCache(10, { time: earlier(), visit: absolute() });
+ const { loader } = mountPanel({ visit: absolute(), time: earlier() });
+ await settle();
+
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(fakes.streams).toHaveLength(0);
+ });
+
+ it("loads another window of the same length instead of restoring it", async () => {
+ await seedCache(10, { time: earlier(), visit: absolute() });
+ const { loader } = mountPanel({ visit: absolute(), time: windowEndingAt(NOW) });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(1);
+ expect(loader.data.value).not.toEqual([hits(10)]);
+ sendHits(0, 7);
+ expect(loader.data.value).toEqual([hits(7)]);
+ });
+
+ it("applies to a panel's own absolute time under a relative global range", async () => {
+ const panel = sqlPanel({
+ panel_time_enabled: true,
+ panel_time_range: { type: "absolute", startTime: 1, endTime: 2 },
+ });
+ const first = mountPanel({ visit: createVisit(), panel, time: earlier() });
+ await settle();
+ endStream(0);
+ await settle();
+ first.wrapper.unmount();
+ fakes.streams.length = 0;
+
+ mountPanel({ visit: createVisit(), panel, time: windowEndingAt(NOW) });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(1);
+ });
+
+ it("keeps restoring a relative range from an earlier window", async () => {
+ await seedCache(10);
+ const { loader } = mountPanel({ visit: createVisit(AUTO_RUN_OFF) });
+ await settle();
+
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(fakes.streams).toHaveLength(0);
+ });
+ });
+ describe("the first load settles", () => {
+ const LOADING_FILTER = [{ ...AD_HOC_FILTER[0], isLoading: true }];
+
+ it("only with the restored result, in the same tick", async () => {
+ await seedCache(10);
+ const { loader } = mountPanel({ visit: createVisit(AUTO_RUN_OFF) });
+ const settledWhenDataArrived: boolean[] = [];
+ watch(loader.data, () => settledWhenDataArrived.push(loader.isFirstLoadSettled.value));
+ expect(loader.isFirstLoadSettled.value).toBe(false);
+
+ await settle();
+
+ expect(loader.data.value).toEqual([hits(10)]);
+ expect(loader.isFirstLoadSettled.value).toBe(true);
+ expect(settledWhenDataArrived).toEqual([true]);
+ });
+
+ it("when a cold first load finishes", async () => {
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+ expect(loader.loading.value).toBe(true);
+ expect(loader.isFirstLoadSettled.value).toBe(false);
+
+ endStream(0);
+
+ expect(loader.isFirstLoadSettled.value).toBe(true);
+ });
+
+ it("when the first load fails", async () => {
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+
+ const { payload, handlers } = fakes.streams[0];
+ handlers.error(payload, { content: { message: "querier timed out", code: 504 } });
+
+ expect(loader.isFirstLoadSettled.value).toBe(true);
+ });
+
+ it("when the first load is cancelled before it queries", async () => {
+ const { loader } = mountPanel({ visit: createVisit(), variables: LOADING_FILTER });
+ await settle();
+ expect(loader.isFirstLoadSettled.value).toBe(false);
+
+ window.dispatchEvent(new Event("cancelQuery"));
+ await settle();
+
+ expect(fakes.streams).toHaveLength(0);
+ expect(loader.isFirstLoadSettled.value).toBe(true);
+ });
+
+ it("with nothing to query", async () => {
+ const panel = { ...sqlPanel(), queries: [{ query: "", fields: {}, config: {} }] };
+ const { loader } = mountPanel({ visit: createVisit(), panel });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(0);
+ expect(loader.isFirstLoadSettled.value).toBe(true);
+ });
+
+ it("when the time range is not usable", async () => {
+ const { loader } = mountPanel({ visit: createVisit(), time: {} });
+ await settle();
+
+ expect(fakes.streams).toHaveLength(0);
+ expect(loader.isFirstLoadSettled.value).toBe(true);
+ });
+
+ it("with injected PromQL data", async () => {
+ const injected = { data: [{ resultType: "matrix", result: [] }], metadata: { queries: [] } };
+ const { loader } = mountPanel({ visit: null, injected });
+ await settle();
+
+ expect(loader.data.value).toEqual(injected.data);
+ expect(loader.isFirstLoadSettled.value).toBe(true);
+ });
+
+ it("never while the panel is off screen", async () => {
+ await seedCache(10);
+ global.IntersectionObserver = vi.fn(function (this: any, callback: any) {
+ this.observe = () => callback([{ isIntersecting: false }]);
+ this.disconnect = vi.fn();
+ }) as any;
+ const { loader } = mountPanel({ visit: createVisit() });
+ await settle();
+
+ expect(loader.data.value).toEqual([]);
+ expect(loader.isFirstLoadSettled.value).toBe(false);
+ });
+
+ it("not from a call that a newer one superseded", async () => {
+ const { loader, selectedTimeObj } = mountPanel({
+ visit: createVisit(),
+ variables: LOADING_FILTER,
+ });
+ await settle();
+
+ selectedTimeObj.value = windowEndingAt(NOW + 60_000);
+ await settle();
+
+ expect(loader.isFirstLoadSettled.value).toBe(false);
+ });
+ });
+});
diff --git a/web/src/composables/dashboard/usePanelDataLoader.ts b/web/src/composables/dashboard/usePanelDataLoader.ts
index 896b31afdb..b5e7bee92f 100644
--- a/web/src/composables/dashboard/usePanelDataLoader.ts
+++ b/web/src/composables/dashboard/usePanelDataLoader.ts
@@ -30,6 +30,7 @@ import { useStore } from "vuex";
import config from "@/aws-exports";
import { usePanelCache } from "./usePanelCache";
+import { usePanelRefreshAfterRestore } from "./usePanelRefreshAfterRestore";
import { isEqual, omit } from "lodash-es";
import { useAnnotations } from "./useAnnotations";
@@ -196,15 +197,31 @@ export const usePanelDataLoader = (
// is panel currently visible or not
const isVisible: any = ref(false);
+ const refreshAfterRestore = usePanelRefreshAfterRestore({ state, panelSchema, dashboardId });
+
+ // Until the first load settles the panel has no answer yet, so "No Data" would flash over a cached result.
+ const isFirstLoadSettled = ref(false);
+ let latestLoadCall = 0;
+ watch(
+ () => state.loading,
+ (loading) => {
+ if (!loading) isFirstLoadSettled.value = true;
+ },
+ { flush: "sync" },
+ );
+
+ const writeStateToCache = async (cacheTimeRange: { start_time: number; end_time: number }) => {
+ // Restore rejects an incomplete state, so writing one would only delete the last complete entry.
+ if (state.loading || state.isPartialData) return;
+ await savePanelCache(getCacheKey(), { ...toRaw(state) }, cacheTimeRange);
+ };
+
const saveCurrentStateToCache = async () => {
- await savePanelCache(
- getCacheKey(),
- { ...toRaw(state) },
- {
- start_time: selectedTimeObj?.value?.start_time?.getTime(),
- end_time: selectedTimeObj?.value?.end_time?.getTime(),
- },
- );
+ refreshAfterRestore.markIfComplete();
+ await writeStateToCache({
+ start_time: selectedTimeObj?.value?.start_time?.getTime(),
+ end_time: selectedTimeObj?.value?.end_time?.getTime(),
+ });
};
// The window the displayed data was produced with — the executed range on a
@@ -222,11 +239,7 @@ export const usePanelDataLoader = (
// Write through, or the cached panel entry restores the stale list. Keep
// the entry's cacheTimeRange as the data's window — the picker may have
// moved since the displayed result was produced.
- await savePanelCache(
- getCacheKey(),
- { ...toRaw(state) },
- { start_time: window.start, end_time: window.end },
- );
+ await writeStateToCache({ start_time: window.start, end_time: window.end });
} catch (error) {
console.error("Failed to refresh annotations:", error);
}
@@ -338,8 +351,9 @@ export const usePanelDataLoader = (
};
const cancelQueryAbort = () => {
+ const keptRestoredResult = refreshAfterRestore.release(true);
// Only set isPartialData to true if the panel was still loading
- if (state.loading) {
+ if (state.loading && !keptRestoredResult) {
state.isPartialData = true; // Set to true when cancelled
}
state.loading = false;
@@ -362,7 +376,8 @@ export const usePanelDataLoader = (
if (abortController) {
abortController?.abort();
}
- saveCurrentStateToCache();
+ // The restored entry is still the last complete result; re-saving would stamp it with the new window.
+ if (!keptRestoredResult) saveCurrentStateToCache();
};
// Aborting the controller only stops the client; the superseded searches keep running server-side until cancelled.
@@ -385,6 +400,7 @@ export const usePanelDataLoader = (
};
const loadData = async () => {
+ const loadCall = ++latestLoadCall;
log("[usePanelDataLoader] " + panelSchema?.value?.title + ": loadData() PROCEEDING");
// Only reset isPartialData if we're starting a fresh load and not restoring from cache
@@ -464,7 +480,7 @@ export const usePanelDataLoader = (
state.isOperationCancelled = false;
log("loadData: panelcache: restored from cache");
runCount++;
- return;
+ if (!refreshAfterRestore.queueRefresh()) return;
}
}
@@ -500,6 +516,7 @@ export const usePanelDataLoader = (
runCount++;
+ refreshAfterRestore.holdIfQueued();
state.loading = true;
state.isCachedDataDifferWithCurrentTimeRange = false;
@@ -524,6 +541,9 @@ export const usePanelDataLoader = (
} else {
log("logaData: An error occurred:", error);
}
+ } finally {
+ // Covers every early return: a call that was not superseded and left nothing running has its answer.
+ if (loadCall === latestLoadCall && !state.loading) isFirstLoadSettled.value = true;
}
};
@@ -685,7 +705,8 @@ export const usePanelDataLoader = (
searchType,
searchResponse,
is_ui_histogram,
- shouldRefreshWithoutCache,
+ shouldRefreshWithoutCache:
+ refreshAfterRestore.clearCacheUnlessHolding(shouldRefreshWithoutCache),
dashboardId,
dashboardName,
folderId,
@@ -779,13 +800,6 @@ export const usePanelDataLoader = (
}
abortController.abort();
}
- // Save current state to cache only if still loading on unmount ΓÇö
- // MUST come after isPartialData is set above so the cache stores the correct flag.
- // (handles tab-switch / navigate-away mid-load scenario)
- // If loading is already done, the end/complete handlers already saved the cache.
- if (state.loading) {
- saveCurrentStateToCache();
- }
if (observer) {
observer.disconnect();
}
@@ -871,7 +885,8 @@ export const usePanelDataLoader = (
tempPanelCacheValue &&
Object.keys(tempPanelCacheValue).length > 0 &&
cacheKeysMatch &&
- !cachedIncompleteLoad
+ !cachedIncompleteLoad &&
+ refreshAfterRestore.acceptsCachedWindow(cache.cacheTimeRange, selectedTimeObj?.value)
) {
// const cache = getPanelCache();
state.data = markRaw(tempPanelCacheValue.data ?? []);
@@ -887,6 +902,8 @@ export const usePanelDataLoader = (
// set that the cache is restored
isRestoredFromCache = true;
+ // Same tick as the restored data, so the renderer's first flush already converts it.
+ isFirstLoadSettled.value = true;
if (cache?.cacheTimeRange?.start_time && cache?.cacheTimeRange?.end_time) {
renderedTimeRange = {
@@ -911,6 +928,8 @@ export const usePanelDataLoader = (
return {
...toRefs(state),
+ ...refreshAfterRestore.shownResult,
+ isFirstLoadSettled,
loadData,
reloadAnnotations,
};
diff --git a/web/src/composables/dashboard/usePanelRefreshAfterRestore.spec.ts b/web/src/composables/dashboard/usePanelRefreshAfterRestore.spec.ts
new file mode 100644
index 0000000000..90060212fd
--- /dev/null
+++ b/web/src/composables/dashboard/usePanelRefreshAfterRestore.spec.ts
@@ -0,0 +1,166 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, expect, it } from "vitest";
+import { defineComponent, h, provide, reactive, ref } from "vue";
+import { mount } from "@vue/test-utils";
+import { usePanelRefreshAfterRestore } from "./usePanelRefreshAfterRestore";
+import { createDashboardFreshOnOpen, DASHBOARD_FRESH_ON_OPEN } from "./dashboardFreshOnOpen";
+
+const restoredState = () =>
+ reactive({
+ data: [["restored"]] as any,
+ metadata: { queries: [] } as any,
+ resultMetaData: [] as any,
+ annotations: [] as any,
+ errorDetail: { message: "", code: "" } as any,
+ sparklineData: [] as any,
+ sparklineWarning: "",
+ lastTriggeredAt: 1000 as any,
+ isCachedDataDifferWithCurrentTimeRange: false,
+ isPartialData: false,
+ loading: false,
+ isOperationCancelled: false,
+ });
+
+const setup = ({ autoRun = true, relative = true } = {}) => {
+ const state = restoredState();
+ const visit = createDashboardFreshOnOpen({
+ isAutoRunOn: () => autoRun,
+ isRelativeRange: () => relative,
+ });
+ let refresh: any;
+ const Panel = defineComponent({
+ setup() {
+ refresh = usePanelRefreshAfterRestore({
+ state,
+ panelSchema: ref({ id: "P1" }),
+ dashboardId: ref("D1"),
+ });
+ return () => h("div");
+ },
+ });
+ mount(
+ defineComponent({
+ setup() {
+ provide(DASHBOARD_FRESH_ON_OPEN, visit);
+ return () => h(Panel);
+ },
+ }),
+ );
+ return { state, visit, refresh };
+};
+
+const WINDOW = { start_time: new Date(1_000_000), end_time: new Date(1_900_000) };
+
+// Simulates an executor starting the refresh run.
+const startRun = (state: any, refresh: any) => {
+ refresh.holdIfQueued();
+ state.loading = true;
+ state.data = [];
+};
+
+describe("usePanelRefreshAfterRestore", () => {
+ it("never queues outside a dashboard view", () => {
+ const state = restoredState();
+ const refresh = usePanelRefreshAfterRestore({ state, panelSchema: ref({ id: "P1" }) });
+
+ expect(refresh.queueRefresh()).toBe(false);
+ startRun(state, refresh);
+ expect(refresh.shownResult.data.value).toEqual([]);
+ });
+
+ it("holds only a queued refresh", () => {
+ const { state, refresh } = setup();
+
+ startRun(state, refresh);
+ expect(refresh.shownResult.data.value).toEqual([]);
+
+ state.data = [["restored"]];
+ state.loading = false;
+ expect(refresh.queueRefresh()).toBe(true);
+ startRun(state, refresh);
+ expect(refresh.shownResult.data.value).toEqual([["restored"]]);
+ });
+
+ it("shows a write made while holding, and the run's result once it settles", () => {
+ const { state, refresh } = setup();
+ refresh.queueRefresh();
+ startRun(state, refresh);
+
+ refresh.shownResult.errorDetail.value = { message: "render failed", code: "" };
+ expect(refresh.shownResult.errorDetail.value.message).toBe("render failed");
+
+ state.data = [["fresh"]];
+ state.loading = false;
+ expect(refresh.shownResult.data.value).toEqual([["fresh"]]);
+ expect(refresh.shownResult.errorDetail.value.message).toBe("");
+ });
+
+ it("puts the restored result back when released early", () => {
+ const { state, refresh } = setup();
+ refresh.queueRefresh();
+ startRun(state, refresh);
+
+ expect(refresh.release(true)).toBe(true);
+ expect(state.data).toEqual([["restored"]]);
+ expect(state.lastTriggeredAt).toBe(1000);
+ expect(refresh.release(true)).toBe(false);
+ });
+
+ it("accepts any cached window for a relative range and outside dashboards", () => {
+ const { refresh } = setup();
+ const outside = usePanelRefreshAfterRestore({
+ state: restoredState(),
+ panelSchema: ref({ id: "P1" }),
+ });
+
+ expect(refresh.acceptsCachedWindow({ start_time: 1, end_time: 2 }, WINDOW)).toBe(true);
+ expect(outside.acceptsCachedWindow({ start_time: 1, end_time: 2 }, WINDOW)).toBe(true);
+ });
+
+ it("accepts only the exact window for an absolute range, whatever the auto-run setting", () => {
+ const { refresh } = setup({ autoRun: false, relative: false });
+ const sameWindow = {
+ start_time: WINDOW.start_time.getTime(),
+ end_time: WINDOW.end_time.getTime(),
+ };
+ const sameLengthLater = {
+ start_time: sameWindow.start_time + 60_000,
+ end_time: sameWindow.end_time + 60_000,
+ };
+
+ expect(refresh.acceptsCachedWindow(sameWindow, WINDOW)).toBe(true);
+ expect(refresh.acceptsCachedWindow(sameLengthLater, WINDOW)).toBe(false);
+ expect(refresh.acceptsCachedWindow(undefined, WINDOW)).toBe(false);
+ });
+
+ it("marks only a complete, error-free result as refreshed", () => {
+ const { state, visit, refresh } = setup();
+
+ state.errorDetail = { message: "boom", code: 500 };
+ refresh.markIfComplete();
+ expect(visit.wasRefreshed("D1/P1")).toBe(false);
+
+ state.errorDetail = { message: "", code: "" };
+ state.isPartialData = true;
+ refresh.markIfComplete();
+ expect(visit.wasRefreshed("D1/P1")).toBe(false);
+
+ state.isPartialData = false;
+ refresh.markIfComplete();
+ expect(visit.wasRefreshed("D1/P1")).toBe(true);
+ });
+});
diff --git a/web/src/composables/dashboard/usePanelRefreshAfterRestore.ts b/web/src/composables/dashboard/usePanelRefreshAfterRestore.ts
new file mode 100644
index 0000000000..b5a53b86fd
--- /dev/null
+++ b/web/src/composables/dashboard/usePanelRefreshAfterRestore.ts
@@ -0,0 +1,147 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import {
+ computed,
+ hasInjectionContext,
+ inject,
+ ref,
+ watch,
+ type Ref,
+ type WritableComputedRef,
+} from "vue";
+import { DASHBOARD_FRESH_ON_OPEN } from "./dashboardFreshOnOpen";
+
+// Result fields a refresh after a cache restore keeps showing from the cache until its run settles.
+const HELD_RESULT_KEYS = [
+ "data",
+ "metadata",
+ "resultMetaData",
+ "annotations",
+ "errorDetail",
+ "sparklineData",
+ "sparklineWarning",
+ "lastTriggeredAt",
+ "isCachedDataDifferWithCurrentTimeRange",
+ "isPartialData",
+] as const;
+
+type HeldKey = (typeof HELD_RESULT_KEYS)[number];
+
+type PanelLoaderState = Record & {
+ loading: boolean;
+ isOperationCancelled: boolean;
+};
+
+type TimeWindow = { start_time?: unknown; end_time?: unknown } | null | undefined;
+
+const toMs = (time: unknown) => (time instanceof Date ? time.getTime() : Number(time));
+
+const isSameWindow = (a: TimeWindow, b: TimeWindow) =>
+ toMs(a?.start_time) === toMs(b?.start_time) && toMs(a?.end_time) === toMs(b?.end_time);
+
+/** Dashboard-view restore policy: an absolute range restores only its own window; a relative one restores, then re-queries. */
+export const usePanelRefreshAfterRestore = ({
+ state,
+ panelSchema,
+ dashboardId,
+}: {
+ state: S;
+ panelSchema: Ref;
+ dashboardId?: Ref;
+}) => {
+ const freshOnOpen = hasInjectionContext() ? inject(DASHBOARD_FRESH_ON_OPEN, null) : null;
+ const panelKey = () => `${dashboardId?.value}/${panelSchema.value?.id}`;
+
+ let queued = false;
+ // The executors keep writing to state; this is what the panel shows until the run settles.
+ const heldResult = ref | null>(null);
+
+ // The cache key has no time range, so another absolute window of the same length would pass for this one.
+ const acceptsCachedWindow = (cachedWindow: TimeWindow, selectedWindow: TimeWindow) =>
+ freshOnOpen?.isRelativeRange(panelSchema.value) !== false ||
+ isSameWindow(cachedWindow, selectedWindow);
+
+ // A restore from another window length is not fresh, even for a panel already refreshed this visit.
+ const queueRefresh = () => {
+ queued =
+ !!freshOnOpen?.appliesTo(panelSchema.value) &&
+ (!freshOnOpen.wasRefreshed(panelKey()) || !!state.isCachedDataDifferWithCurrentTimeRange);
+ return queued;
+ };
+
+ // Held at query start, not at restore, so a run superseded while waiting for variables still holds.
+ const holdIfQueued = () => {
+ if (!queued) return;
+ queued = false;
+ const restored = Object.fromEntries(HELD_RESULT_KEYS.map((key) => [key, state[key]]));
+ heldResult.value = restored as Pick;
+ state.lastTriggeredAt = new Date().getTime();
+ };
+
+ const release = (keepRestored: boolean) => {
+ queued = false;
+ const held = heldResult.value;
+ if (!held) return false;
+ heldResult.value = null;
+ if (keepRestored) Object.assign(state, held);
+ return true;
+ };
+
+ // Sync, so the settled result replaces the restored one before any consumer sees the gap.
+ watch(
+ () => state.loading,
+ (loading) => {
+ if (!loading) release(false);
+ },
+ { flush: "sync" },
+ );
+
+ // "Refresh cache & reload" stays set for the visit, but this refresh must read the server cache.
+ const clearCacheUnlessHolding = (shouldRefreshWithoutCache?: Ref) =>
+ computed(() => heldResult.value === null && !!shouldRefreshWithoutCache?.value);
+
+ // Any complete result cached this visit is fresh, whichever run produced it.
+ const markIfComplete = () => {
+ const isComplete =
+ !state.loading &&
+ !state.isPartialData &&
+ !state.isOperationCancelled &&
+ !state.errorDetail?.message;
+ if (isComplete) freshOnOpen?.markRefreshed(panelKey());
+ };
+
+ const shownResult = Object.fromEntries(
+ HELD_RESULT_KEYS.map((key) => [
+ key,
+ computed({
+ get: () => (heldResult.value ?? state)[key],
+ set: (value: any) => {
+ ((heldResult.value ?? state) as Record)[key] = value;
+ },
+ }),
+ ]),
+ ) as { [K in HeldKey]: WritableComputedRef };
+
+ return {
+ acceptsCachedWindow,
+ queueRefresh,
+ holdIfQueued,
+ release,
+ clearCacheUnlessHolding,
+ markIfComplete,
+ shownResult,
+ };
+};
diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json
index 28b35efd80..55fbcd9462 100644
--- a/web/src/locales/languages/ar-SA.json
+++ b/web/src/locales/languages/ar-SA.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "فشل نقل اللوحة",
"failedToFetchReports": "فشل في جلب التقارير",
"failedToUpdateJson": "فشل تحديث لوحة التحكم JSON: طريقة الحفظ غير متوفرة",
- "failedToSaveChanges": "فشل في حفظ تغييرات لوحة التحكم"
+ "failedToSaveChanges": "فشل في حفظ تغييرات لوحة التحكم",
+ "autoRunDescription": "يشغّل الاستعلامات عند الفتح وعند تغييرات عامل التصفية/الوقت",
+ "autoRunScope": "ينطبق على جميع لوحات المعلومات والسجلات والآثار"
},
"dynamicFunctionPopUp": {
"property": "الملكية",
@@ -7143,7 +7145,9 @@
"markerAria": "النموذج {value} عند {time}"
},
"showExemplarsLabel": "إظهار النماذج",
- "showExemplarsHelp": "يرسم عينات الآثار على هذا المخطط لكل من يفتح لوحة المعلومات. لا يزال بإمكان المشاهدين إخفاؤها من رأس اللوحة."
+ "showExemplarsHelp": "يرسم عينات الآثار على هذا المخطط لكل من يفتح لوحة المعلومات. لا يزال بإمكان المشاهدين إخفاؤها من رأس اللوحة.",
+ "refreshToApplyTimeRange": "تحديث لتطبيق النطاق الزمني",
+ "refreshToApplyTimeRangeAndVariables": "تحديث لتطبيق النطاق الزمني وتغييرات المتغيرات"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json
index eb9fdaa59c..8451a01076 100644
--- a/web/src/locales/languages/de-DE.json
+++ b/web/src/locales/languages/de-DE.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Panel-Verschiebung fehlgeschlagen",
"failedToFetchReports": "Berichte konnten nicht abgerufen werden",
"failedToUpdateJson": "Dashboard-JSON konnte nicht aktualisiert werden: Speichermethode nicht verfügbar",
- "failedToSaveChanges": "Dashboard-Änderungen konnten nicht gespeichert werden"
+ "failedToSaveChanges": "Dashboard-Änderungen konnten nicht gespeichert werden",
+ "autoRunDescription": "Führt Abfragen beim Öffnen und bei Filter-/Zeitänderungen aus",
+ "autoRunScope": "Gilt für alle Dashboards, Logs und Traces"
},
"dynamicFunctionPopUp": {
"property": "Eigenschaft",
@@ -7143,7 +7145,9 @@
"markerAria": "Exemplar {value} um {time}"
},
"showExemplarsLabel": "Exemplare anzeigen",
- "showExemplarsHelp": "Zeichnet Trace-Stichproben in diesem Diagramm für alle, die das Dashboard öffnen. Betrachter können sie weiterhin über die Panel-Kopfzeile ausblenden."
+ "showExemplarsHelp": "Zeichnet Trace-Stichproben in diesem Diagramm für alle, die das Dashboard öffnen. Betrachter können sie weiterhin über die Panel-Kopfzeile ausblenden.",
+ "refreshToApplyTimeRange": "Aktualisieren, um den Zeitbereich anzuwenden",
+ "refreshToApplyTimeRangeAndVariables": "Aktualisieren, um den Zeitbereich und Variablenänderungen anzuwenden"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json
index 3e27b43f66..a67b0e878c 100644
--- a/web/src/locales/languages/en-US.json
+++ b/web/src/locales/languages/en-US.json
@@ -6680,6 +6680,8 @@
"refresh": "Refresh",
"refreshCacheReload": "Refresh cache & reload",
"moreRefreshOptions": "More refresh options",
+ "autoRunDescription": "Runs queries on open and on filter/time changes",
+ "autoRunScope": "Applies to all dashboards, Logs and Traces",
"dashboard": "Dashboard",
"dashboardNotFound": "Dashboard not found or has been deleted. Redirecting to dashboard list.",
"failedToLoadDashboard": "Failed to load dashboard. Redirecting to dashboard list.",
@@ -6962,6 +6964,8 @@
"exitAnnotationsMode": "Exit Annotations Mode",
"addAnnotations": "Add Annotations",
"refreshToApplyVariables": "Refresh to apply latest variable changes",
+ "refreshToApplyTimeRange": "Refresh to apply the time range",
+ "refreshToApplyTimeRangeAndVariables": "Refresh to apply the time range and variable changes",
"moveToAnotherFolder": "Move {type} To Another Folder",
"rawQueryBuilder": {
"query": "Query",
diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json
index 37af76937e..69e219c3d0 100644
--- a/web/src/locales/languages/es-ES.json
+++ b/web/src/locales/languages/es-ES.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Error al mover el panel",
"failedToFetchReports": "Error al obtener los reportes",
"failedToUpdateJson": "Error al actualizar el JSON del dashboard: método de guardado no disponible",
- "failedToSaveChanges": "Error al guardar los cambios del dashboard"
+ "failedToSaveChanges": "Error al guardar los cambios del dashboard",
+ "autoRunDescription": "Ejecuta las consultas al abrir y ante cambios de filtro o tiempo",
+ "autoRunScope": "Se aplica a todos los dashboards, Logs y Trazas"
},
"dynamicFunctionPopUp": {
"property": "Propiedad",
@@ -7143,7 +7145,9 @@
"markerAria": "Ejemplar {value} a las {time}"
},
"showExemplarsLabel": "Mostrar ejemplares",
- "showExemplarsHelp": "Dibuja muestras de trazas en este gráfico para todos los que abran el dashboard. Los visores aún pueden ocultarlas desde el encabezado del panel."
+ "showExemplarsHelp": "Dibuja muestras de trazas en este gráfico para todos los que abran el dashboard. Los visores aún pueden ocultarlas desde el encabezado del panel.",
+ "refreshToApplyTimeRange": "Actualiza para aplicar el rango de tiempo",
+ "refreshToApplyTimeRangeAndVariables": "Actualiza para aplicar el rango de tiempo y los cambios de variables"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json
index ff6b17afc5..1839a89fc4 100644
--- a/web/src/locales/languages/fr-FR.json
+++ b/web/src/locales/languages/fr-FR.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Échec du déplacement du panneau",
"failedToFetchReports": "Échec de la récupération des rapports",
"failedToUpdateJson": "Échec de la mise à jour du JSON du tableau de bord : méthode de sauvegarde non disponible",
- "failedToSaveChanges": "Échec de la sauvegarde des modifications du tableau de bord"
+ "failedToSaveChanges": "Échec de la sauvegarde des modifications du tableau de bord",
+ "autoRunDescription": "Exécute les requêtes à l'ouverture et lors des modifications de filtre ou de plage horaire",
+ "autoRunScope": "S'applique à tous les tableaux de bord, Logs et Traces"
},
"dynamicFunctionPopUp": {
"property": "Propriété",
@@ -7143,7 +7145,9 @@
"markerAria": "Exemplaire {value} à {time}"
},
"showExemplarsLabel": "Afficher les exemplaires",
- "showExemplarsHelp": "Dessine les échantillons de traces sur ce graphique pour toute personne qui ouvre le tableau de bord. Les spectateurs peuvent toujours les masquer depuis l'en-tête du panneau."
+ "showExemplarsHelp": "Dessine les échantillons de traces sur ce graphique pour toute personne qui ouvre le tableau de bord. Les spectateurs peuvent toujours les masquer depuis l'en-tête du panneau.",
+ "refreshToApplyTimeRange": "Actualisez pour appliquer la plage horaire",
+ "refreshToApplyTimeRangeAndVariables": "Actualisez pour appliquer la plage horaire et les modifications de variables"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json
index 5ac4499266..db41e3dc73 100644
--- a/web/src/locales/languages/it-IT.json
+++ b/web/src/locales/languages/it-IT.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Spostamento pannello fallito",
"failedToFetchReports": "Impossibile recuperare i rapporti",
"failedToUpdateJson": "Impossibile aggiornare il JSON del dashboard: metodo di salvataggio non disponibile",
- "failedToSaveChanges": "Impossibile salvare le modifiche al dashboard"
+ "failedToSaveChanges": "Impossibile salvare le modifiche al dashboard",
+ "autoRunDescription": "Esegue le query all'apertura e quando cambiano filtri o intervallo di tempo",
+ "autoRunScope": "Si applica a tutte le dashboard, Log e Tracce"
},
"dynamicFunctionPopUp": {
"property": "Proprietà",
@@ -7143,7 +7145,9 @@
"markerAria": "Esemplare {value} a {time}"
},
"showExemplarsLabel": "Mostra esemplari",
- "showExemplarsHelp": "Disegna campioni di traccia su questo grafico per tutti coloro che aprono la dashboard. I visualizzatori possono comunque nasconderli dall'intestazione del pannello."
+ "showExemplarsHelp": "Disegna campioni di traccia su questo grafico per tutti coloro che aprono la dashboard. I visualizzatori possono comunque nasconderli dall'intestazione del pannello.",
+ "refreshToApplyTimeRange": "Aggiorna per applicare l'intervallo di tempo",
+ "refreshToApplyTimeRangeAndVariables": "Aggiorna per applicare l'intervallo di tempo e le modifiche alle variabili"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json
index dc41f9f4b2..005a285224 100644
--- a/web/src/locales/languages/ja-JP.json
+++ b/web/src/locales/languages/ja-JP.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "パネルの移動に失敗しました",
"failedToFetchReports": "レポートの取得に失敗しました",
"failedToUpdateJson": "ダッシュボードJSONの更新に失敗しました:保存メソッドが利用できません",
- "failedToSaveChanges": "ダッシュボードの変更の保存に失敗しました"
+ "failedToSaveChanges": "ダッシュボードの変更の保存に失敗しました",
+ "autoRunDescription": "開いたときとフィルター/時間変更時にクエリを実行します",
+ "autoRunScope": "すべてのダッシュボード、ログ、トレースに適用されます"
},
"dynamicFunctionPopUp": {
"property": "プロパティ",
@@ -7143,7 +7145,9 @@
"markerAria": "{time} のエグザンプラー {value}"
},
"showExemplarsLabel": "エグザンプラーを表示",
- "showExemplarsHelp": "このダッシュボードを開くすべてのユーザーに対して、このグラフにトレースサンプルを描画します。閲覧者はパネルヘッダーから非表示にできます。"
+ "showExemplarsHelp": "このダッシュボードを開くすべてのユーザーに対して、このグラフにトレースサンプルを描画します。閲覧者はパネルヘッダーから非表示にできます。",
+ "refreshToApplyTimeRange": "時間範囲を適用するには更新してください",
+ "refreshToApplyTimeRangeAndVariables": "時間範囲と変数の変更を適用するには更新してください"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json
index 999668d3e9..50f685cf77 100644
--- a/web/src/locales/languages/ko-KR.json
+++ b/web/src/locales/languages/ko-KR.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "패널 이동 실패",
"failedToFetchReports": "리포트를 가져오지 못했습니다.",
"failedToUpdateJson": "대시보드 JSON 업데이트 실패: 저장 메서드를 사용할 수 없습니다.",
- "failedToSaveChanges": "대시보드 변경 사항 저장 실패"
+ "failedToSaveChanges": "대시보드 변경 사항 저장 실패",
+ "autoRunDescription": "열 때와 필터/시간 변경 시 쿼리를 실행합니다",
+ "autoRunScope": "모든 대시보드, 로그 및 트레이스에 적용됩니다"
},
"dynamicFunctionPopUp": {
"property": "속성",
@@ -7143,7 +7145,9 @@
"markerAria": "{time}의 예시 {value}"
},
"showExemplarsLabel": "예시 표시",
- "showExemplarsHelp": "대시보드를 여는 모든 사람을 위해 이 차트에 트레이스 샘플을 그립니다. 뷰어는 패널 헤더에서 여전히 숨길 수 있습니다."
+ "showExemplarsHelp": "대시보드를 여는 모든 사람을 위해 이 차트에 트레이스 샘플을 그립니다. 뷰어는 패널 헤더에서 여전히 숨길 수 있습니다.",
+ "refreshToApplyTimeRange": "시간 범위를 적용하려면 새로고침하세요",
+ "refreshToApplyTimeRangeAndVariables": "시간 범위와 변수 변경 사항을 적용하려면 새로고침하세요"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json
index 533481f530..8fa5fd8225 100644
--- a/web/src/locales/languages/nl-NL.json
+++ b/web/src/locales/languages/nl-NL.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Paneel verplaatsen mislukt",
"failedToFetchReports": "Kan rapporten niet ophalen",
"failedToUpdateJson": "Kan dashboard-JSON niet bijwerken: opslagmethode niet beschikbaar",
- "failedToSaveChanges": "Kan dashboardwijzigingen niet opslaan"
+ "failedToSaveChanges": "Kan dashboardwijzigingen niet opslaan",
+ "autoRunDescription": "Voert query's uit bij openen en bij filter- of tijdwijzigingen.",
+ "autoRunScope": "Geldt voor alle dashboards, Logboeken en Traces."
},
"dynamicFunctionPopUp": {
"property": "Eigenschap",
@@ -7143,7 +7145,9 @@
"markerAria": "Exemplaar {value} op {time}"
},
"showExemplarsLabel": "Exemplaren tonen",
- "showExemplarsHelp": "Tekent trace-voorbeelden op deze grafiek voor iedereen die het dashboard opent. Kijkers kunnen ze nog steeds verbergen via de paneelheader."
+ "showExemplarsHelp": "Tekent trace-voorbeelden op deze grafiek voor iedereen die het dashboard opent. Kijkers kunnen ze nog steeds verbergen via de paneelheader.",
+ "refreshToApplyTimeRange": "Vernieuwen om het tijdsbereik toe te passen.",
+ "refreshToApplyTimeRangeAndVariables": "Vernieuwen om het tijdsbereik en wijzigingen aan variabelen toe te passen."
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json
index 77dce33d1a..e44b121153 100644
--- a/web/src/locales/languages/pl-PL.json
+++ b/web/src/locales/languages/pl-PL.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Przeniesienie panelu nie powiodło się",
"failedToFetchReports": "Nie udało się pobrać raportów",
"failedToUpdateJson": "Nie udało się zaktualizować JSON pulpitu nawigacyjnego: metoda zapisu niedostępna",
- "failedToSaveChanges": "Nie udało się zapisać zmian na pulpicie nawigacyjnym"
+ "failedToSaveChanges": "Nie udało się zapisać zmian na pulpicie nawigacyjnym",
+ "autoRunDescription": "Uruchamia zapytania przy otwarciu oraz przy zmianach filtra/czasu",
+ "autoRunScope": "Dotyczy wszystkich pulpitów, Logów i Trace"
},
"dynamicFunctionPopUp": {
"property": "Właściwość",
@@ -7143,7 +7145,9 @@
"markerAria": "Exemplar {value} o {time}"
},
"showExemplarsLabel": "Pokaż exemplary",
- "showExemplarsHelp": "Rysuje próbki trace na tym wykresie dla wszystkich, którzy otworzą pulpit. Przeglądający nadal mogą je ukryć w nagłówku panelu."
+ "showExemplarsHelp": "Rysuje próbki trace na tym wykresie dla wszystkich, którzy otworzą pulpit. Przeglądający nadal mogą je ukryć w nagłówku panelu.",
+ "refreshToApplyTimeRange": "Odśwież, aby zastosować zakres czasu",
+ "refreshToApplyTimeRangeAndVariables": "Odśwież, aby zastosować zakres czasu i zmiany zmiennych"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json
index 2b8cda5816..1791a6b96b 100644
--- a/web/src/locales/languages/pt-PT.json
+++ b/web/src/locales/languages/pt-PT.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Falha ao mover o painel",
"failedToFetchReports": "Falha ao buscar relatórios",
"failedToUpdateJson": "Falha ao atualizar o JSON do dashboard: método de salvamento não disponível",
- "failedToSaveChanges": "Falha ao salvar alterações no dashboard"
+ "failedToSaveChanges": "Falha ao salvar alterações no dashboard",
+ "autoRunDescription": "Executa consultas ao abrir e em alterações de filtro/tempo",
+ "autoRunScope": "Aplica-se a todos os painéis, Registros e Rastros"
},
"dynamicFunctionPopUp": {
"property": "Propriedade",
@@ -7143,7 +7145,9 @@
"markerAria": "Exemplar {value} em {time}"
},
"showExemplarsLabel": "Mostrar exemplares",
- "showExemplarsHelp": "Desenha amostras de rastros neste gráfico para todos que abrirem o painel. Os visualizadores ainda podem ocultá-las no cabeçalho do painel."
+ "showExemplarsHelp": "Desenha amostras de rastros neste gráfico para todos que abrirem o painel. Os visualizadores ainda podem ocultá-las no cabeçalho do painel.",
+ "refreshToApplyTimeRange": "Atualize para aplicar o intervalo de tempo",
+ "refreshToApplyTimeRangeAndVariables": "Atualize para aplicar o intervalo de tempo e as alterações de variáveis"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json
index 8e2b9247ea..ac6cd603fa 100644
--- a/web/src/locales/languages/ru-RU.json
+++ b/web/src/locales/languages/ru-RU.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Не удалось переместить панель",
"failedToFetchReports": "Не удалось получить отчёты",
"failedToUpdateJson": "Не удалось обновить JSON панели мониторинга: метод сохранения недоступен",
- "failedToSaveChanges": "Не удалось сохранить изменения панели мониторинга"
+ "failedToSaveChanges": "Не удалось сохранить изменения панели мониторинга",
+ "autoRunDescription": "Запускает запросы при открытии и при изменении фильтра или времени",
+ "autoRunScope": "Применяется ко всем дашбордам, Логам и Трейсам"
},
"dynamicFunctionPopUp": {
"property": "Свойство",
@@ -7143,7 +7145,9 @@
"markerAria": "Образец {value} в {time}"
},
"showExemplarsLabel": "Показать образцы",
- "showExemplarsHelp": "Рисует образцы трейсов на этом графике для всех, кто открывает дашборд. Пользователи по-прежнему могут скрыть их через заголовок панели."
+ "showExemplarsHelp": "Рисует образцы трейсов на этом графике для всех, кто открывает дашборд. Пользователи по-прежнему могут скрыть их через заголовок панели.",
+ "refreshToApplyTimeRange": "Обновите, чтобы применить диапазон времени",
+ "refreshToApplyTimeRangeAndVariables": "Обновите, чтобы применить диапазон времени и изменения переменных"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json
index f5c5bb7159..069f5cf343 100644
--- a/web/src/locales/languages/tr-TR.json
+++ b/web/src/locales/languages/tr-TR.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Panel taşıma başarısız",
"failedToFetchReports": "Raporlar alınamadı",
"failedToUpdateJson": "Pano JSON güncellenemedi: Kaydetme yöntemi mevcut değil",
- "failedToSaveChanges": "Pano değişiklikleri kaydedilemedi"
+ "failedToSaveChanges": "Pano değişiklikleri kaydedilemedi",
+ "autoRunDescription": "Açıldığında ve filtre/zaman değişikliklerinde sorguları çalıştırır",
+ "autoRunScope": "Tüm gösterge panoları, Günlükler ve İzler için geçerlidir"
},
"dynamicFunctionPopUp": {
"property": "Özellik",
@@ -7143,7 +7145,9 @@
"markerAria": "{time} zamanındaki örnek {value}"
},
"showExemplarsLabel": "Örnekleri göster",
- "showExemplarsHelp": "Bu grafikte, gösterge panosunu açan herkes için iz örneklerini çizer. Görüntüleyenler bunları yine de panel başlığından gizleyebilir."
+ "showExemplarsHelp": "Bu grafikte, gösterge panosunu açan herkes için iz örneklerini çizer. Görüntüleyenler bunları yine de panel başlığından gizleyebilir.",
+ "refreshToApplyTimeRange": "Zaman aralığını uygulamak için yenileyin",
+ "refreshToApplyTimeRangeAndVariables": "Zaman aralığını ve değişken değişikliklerini uygulamak için yenileyin"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json
index 1718085502..8ff20e67b8 100644
--- a/web/src/locales/languages/vi-VN.json
+++ b/web/src/locales/languages/vi-VN.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "Di chuyển panel thất bại",
"failedToFetchReports": "Không thể tải báo cáo",
"failedToUpdateJson": "Không thể cập nhật JSON dashboard: Phương thức lưu không khả dụng",
- "failedToSaveChanges": "Không thể lưu thay đổi dashboard"
+ "failedToSaveChanges": "Không thể lưu thay đổi dashboard",
+ "autoRunDescription": "Chạy truy vấn khi mở và khi bộ lọc/thời gian thay đổi",
+ "autoRunScope": "Áp dụng cho tất cả bảng điều khiển, Nhật ký và Trace"
},
"dynamicFunctionPopUp": {
"property": "Thuộc tính",
@@ -7143,7 +7145,9 @@
"markerAria": "Exemplar {value} lúc {time}"
},
"showExemplarsLabel": "Hiển thị exemplar",
- "showExemplarsHelp": "Vẽ mẫu trace trên biểu đồ này cho tất cả những ai mở bảng điều khiển. Người xem vẫn có thể ẩn chúng từ tiêu đề panel."
+ "showExemplarsHelp": "Vẽ mẫu trace trên biểu đồ này cho tất cả những ai mở bảng điều khiển. Người xem vẫn có thể ẩn chúng từ tiêu đề panel.",
+ "refreshToApplyTimeRange": "Làm mới để áp dụng phạm vi thời gian",
+ "refreshToApplyTimeRangeAndVariables": "Làm mới để áp dụng phạm vi thời gian và các thay đổi biến"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json
index 219c76d846..98f69cf9d1 100644
--- a/web/src/locales/languages/zh-CN.json
+++ b/web/src/locales/languages/zh-CN.json
@@ -6689,7 +6689,9 @@
"panelMoveFailed": "面板移动失败",
"failedToFetchReports": "获取报告失败",
"failedToUpdateJson": "更新仪表盘JSON失败:保存方法不可用",
- "failedToSaveChanges": "保存仪表盘更改失败"
+ "failedToSaveChanges": "保存仪表盘更改失败",
+ "autoRunDescription": "打开时以及过滤器/时间变更时运行查询",
+ "autoRunScope": "适用于所有仪表盘、日志和追踪"
},
"dynamicFunctionPopUp": {
"property": "属性",
@@ -7143,7 +7145,9 @@
"markerAria": "示例 {value},时间 {time}"
},
"showExemplarsLabel": "显示示例",
- "showExemplarsHelp": "在打开该仪表盘的所有人的图表上绘制追踪样本。查看者仍可从面板标题栏隐藏它们。"
+ "showExemplarsHelp": "在打开该仪表盘的所有人的图表上绘制追踪样本。查看者仍可从面板标题栏隐藏它们。",
+ "refreshToApplyTimeRange": "刷新以应用时间范围",
+ "refreshToApplyTimeRangeAndVariables": "刷新以应用时间范围和变量变更"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json
index f8d0b55a7e..7f42ebecb8 100644
--- a/web/src/locales/languages/zh-TW.json
+++ b/web/src/locales/languages/zh-TW.json
@@ -6684,7 +6684,9 @@
"panelMoveFailed": "面板移動失敗",
"failedToFetchReports": "取得報告失敗",
"failedToUpdateJson": "更新儀表板 JSON 失敗:儲存方法不可用",
- "failedToSaveChanges": "儲存儀表板變更失敗"
+ "failedToSaveChanges": "儲存儀表板變更失敗",
+ "autoRunDescription": "開啟時以及篩選/時間變更時執行查詢",
+ "autoRunScope": "適用於所有儀表板、日誌與追蹤"
},
"dynamicFunctionPopUp": {
"property": "屬性",
@@ -7138,7 +7140,9 @@
"markerAria": "{time} 的範例 {value}"
},
"showExemplarsLabel": "顯示範例",
- "showExemplarsHelp": "為所有開啟儀表板的人在此圖表上繪製追蹤樣本。檢視者仍可從面板標題隱藏它們。"
+ "showExemplarsHelp": "為所有開啟儀表板的人在此圖表上繪製追蹤樣本。檢視者仍可從面板標題隱藏它們。",
+ "refreshToApplyTimeRange": "重新整理以套用時間範圍",
+ "refreshToApplyTimeRangeAndVariables": "重新整理以套用時間範圍與變數變更"
},
"panel": {
"addPivotShort": "+P",
diff --git a/web/src/utils/dashboard/panelTimeUtils.effectiveRelative.spec.ts b/web/src/utils/dashboard/panelTimeUtils.effectiveRelative.spec.ts
new file mode 100644
index 0000000000..1028e6bb6e
--- /dev/null
+++ b/web/src/utils/dashboard/panelTimeUtils.effectiveRelative.spec.ts
@@ -0,0 +1,80 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, expect, it } from "vitest";
+import { isEffectivePanelTimeRelative } from "./panelTimeUtils";
+
+const RELATIVE = { valueType: "relative" };
+const ABSOLUTE = { valueType: "absolute" };
+
+const panel = (config: Record = {}) => ({ id: "P1", config });
+
+describe("isEffectivePanelTimeRelative", () => {
+ it("follows the global picker for a panel without its own time", () => {
+ expect(isEffectivePanelTimeRelative(panel(), {}, RELATIVE)).toBe(true);
+ expect(isEffectivePanelTimeRelative(panel(), {}, ABSOLUTE)).toBe(false);
+ });
+
+ it("uses the panel's saved range while panel time is on", () => {
+ const relativePanel = panel({
+ panel_time_enabled: true,
+ panel_time_range: { type: "relative", relativeTimePeriod: "1h" },
+ });
+ const absolutePanel = panel({
+ panel_time_enabled: true,
+ panel_time_range: { type: "absolute", startTime: 1, endTime: 2 },
+ });
+
+ expect(isEffectivePanelTimeRelative(relativePanel, {}, ABSOLUTE)).toBe(true);
+ expect(isEffectivePanelTimeRelative(absolutePanel, {}, RELATIVE)).toBe(false);
+ });
+
+ it("ignores a saved range while panel time is off", () => {
+ const panelTimeOff = panel({
+ panel_time_enabled: false,
+ panel_time_range: { type: "absolute", startTime: 1, endTime: 2 },
+ });
+
+ expect(isEffectivePanelTimeRelative(panelTimeOff, {}, RELATIVE)).toBe(true);
+ });
+
+ it("lets the panel's URL time win over its saved range", () => {
+ const savedRelative = panel({
+ panel_time_enabled: true,
+ panel_time_range: { type: "relative", relativeTimePeriod: "1h" },
+ });
+
+ expect(
+ isEffectivePanelTimeRelative(savedRelative, { "pt-from.P1": "1", "pt-to.P1": "2" }, RELATIVE),
+ ).toBe(false);
+ expect(
+ isEffectivePanelTimeRelative(
+ panel({ panel_time_enabled: true }),
+ { "pt-period.P1": "5m" },
+ ABSOLUTE,
+ ),
+ ).toBe(true);
+ });
+
+ it("falls back to the global picker when panel time is on without a range", () => {
+ expect(isEffectivePanelTimeRelative(panel({ panel_time_enabled: true }), {}, RELATIVE)).toBe(
+ true,
+ );
+ });
+
+ it("is not relative before the global picker is set", () => {
+ expect(isEffectivePanelTimeRelative(panel(), {}, null)).toBe(false);
+ });
+});
diff --git a/web/src/utils/dashboard/panelTimeUtils.pickerValues.spec.ts b/web/src/utils/dashboard/panelTimeUtils.pickerValues.spec.ts
new file mode 100644
index 0000000000..144308d4a8
--- /dev/null
+++ b/web/src/utils/dashboard/panelTimeUtils.pickerValues.spec.ts
@@ -0,0 +1,153 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ arePickerValuesEqual,
+ isUsablePickerValue,
+ pickerValueToTimeObj,
+ toPickerSavedDate,
+} from "./panelTimeUtils";
+
+const MINUTE = 60 * 1_000_000;
+
+describe("arePickerValuesEqual", () => {
+ it("compares relative ranges by period only", () => {
+ expect(
+ arePickerValuesEqual(
+ { valueType: "relative", relativeTimePeriod: "15m", startTime: 1 },
+ { type: "relative", relativeTimePeriod: "15m", startTime: 9 },
+ ),
+ ).toBe(true);
+ expect(
+ arePickerValuesEqual(
+ { valueType: "relative", relativeTimePeriod: "15m" },
+ { valueType: "relative", relativeTimePeriod: "1h" },
+ ),
+ ).toBe(false);
+ });
+
+ it("compares absolute ranges by value, whether read from the URL or the picker", () => {
+ expect(
+ arePickerValuesEqual(
+ { valueType: "absolute", startTime: "100", endTime: "200" },
+ { valueType: "absolute", startTime: 100, endTime: 200 },
+ ),
+ ).toBe(true);
+ expect(
+ arePickerValuesEqual(
+ { valueType: "absolute", startTime: 100, endTime: 200 },
+ { valueType: "absolute", startTime: 100, endTime: 300 },
+ ),
+ ).toBe(false);
+ });
+
+ it("never equates a relative and an absolute range, or a range and nothing", () => {
+ expect(
+ arePickerValuesEqual(
+ { valueType: "relative", relativeTimePeriod: "15m" },
+ { valueType: "absolute", startTime: 1, endTime: 2 },
+ ),
+ ).toBe(false);
+ expect(arePickerValuesEqual(null, { valueType: "relative" })).toBe(false);
+ expect(arePickerValuesEqual(null, null)).toBe(true);
+ });
+});
+
+describe("isUsablePickerValue", () => {
+ it.each([
+ [{ valueType: "relative", relativeTimePeriod: "15m" }, true],
+ [{ valueType: "relative", relativeTimePeriod: "2w" }, true],
+ [{ valueType: "relative", relativeTimePeriod: "0m" }, false],
+ [{ valueType: "relative", relativeTimePeriod: "" }, false],
+ [{ valueType: "absolute", startTime: 100, endTime: 200 }, true],
+ [{ valueType: "absolute", startTime: "100", endTime: "200" }, true],
+ [{ valueType: "absolute", startTime: NaN, endTime: 200 }, false],
+ [{ valueType: "absolute", startTime: 100, endTime: undefined }, false],
+ [{ valueType: "absolute", startTime: 200, endTime: 100 }, false],
+ [{ valueType: "absolute", startTime: 200, endTime: 200 }, false],
+ [null, false],
+ ])("%o is usable: %s", (value, usable) => {
+ expect(isUsablePickerValue(value)).toBe(usable);
+ });
+});
+
+describe("toPickerSavedDate", () => {
+ const startMicros = Date.UTC(2026, 9, 13, 18, 25, 22) * 1000;
+ const endMicros = Date.UTC(2026, 9, 13, 18, 50, 30) * 1000;
+
+ it("passes a relative range as its period", () => {
+ expect(toPickerSavedDate({ valueType: "relative", relativeTimePeriod: "1h" }, "UTC")).toEqual({
+ type: "relative",
+ relativeTimePeriod: "1h",
+ });
+ });
+
+ it("writes an absolute range as wall clock in the dashboard's time zone", () => {
+ expect(
+ toPickerSavedDate(
+ { valueType: "absolute", startTime: startMicros, endTime: endMicros },
+ "UTC",
+ ),
+ ).toEqual({
+ type: "absolute",
+ selectedDate: { from: "2026/10/13", to: "2026/10/13" },
+ selectedTime: { startTime: "18:25:22", endTime: "18:50:30" },
+ });
+ });
+
+ it("follows a non-UTC zone across midnight, and reads URL text", () => {
+ expect(
+ toPickerSavedDate(
+ { valueType: "absolute", startTime: String(startMicros), endTime: String(endMicros) },
+ "Asia/Kolkata",
+ ),
+ ).toEqual({
+ type: "absolute",
+ selectedDate: { from: "2026/10/13", to: "2026/10/14" },
+ selectedTime: { startTime: "23:55:22", endTime: "00:20:30" },
+ });
+ });
+});
+
+describe("pickerValueToTimeObj", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-10-13T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("resolves a relative range from now, in the dashboard's microsecond dates", () => {
+ const time = pickerValueToTimeObj({ valueType: "relative", relativeTimePeriod: "15m" })!;
+
+ expect(time.end_time.getTime()).toBe(Date.now() * 1000);
+ expect(time.end_time.getTime() - time.start_time.getTime()).toBe(15 * MINUTE);
+ });
+
+ it("keeps an absolute range, including one read from the URL as text", () => {
+ const time = pickerValueToTimeObj({ valueType: "absolute", startTime: "100", endTime: "200" })!;
+
+ expect(time.start_time.getTime()).toBe(100);
+ expect(time.end_time.getTime()).toBe(200);
+ });
+
+ it("returns nothing for a relative range without a period", () => {
+ expect(pickerValueToTimeObj({ valueType: "relative", relativeTimePeriod: "" })).toBeNull();
+ expect(pickerValueToTimeObj(null)).toBeNull();
+ });
+});
diff --git a/web/src/utils/dashboard/panelTimeUtils.ts b/web/src/utils/dashboard/panelTimeUtils.ts
index 8d5b1d55b6..e5634f2444 100644
--- a/web/src/utils/dashboard/panelTimeUtils.ts
+++ b/web/src/utils/dashboard/panelTimeUtils.ts
@@ -14,6 +14,10 @@
// along with this program. If not, see .
import type { LocationQuery } from "vue-router";
+import { getConsumableRelativeTime } from "@/utils/date";
+import { timestampToTimezoneDate } from "@/utils/timezone";
+
+const RELATIVE_PERIOD_PATTERN = /^[1-9]\d*[smhdwM]$/;
/**
* Panel time range types for picker format
@@ -203,6 +207,89 @@ export const resolvePanelTimeValue = (
return null;
};
+const pickerValueType = (value: any): string | undefined => value?.valueType || value?.type;
+
+/** Whether two picker values name the same range; a relative range is its period, not its moving window. */
+export const arePickerValuesEqual = (first: any, second: any): boolean => {
+ if (!first || !second) return first === second;
+
+ const type = pickerValueType(first);
+ if (type !== pickerValueType(second)) return false;
+ if (type === "relative") return first.relativeTimePeriod === second.relativeTimePeriod;
+
+ return (
+ Number(first.startTime) === Number(second.startTime) &&
+ Number(first.endTime) === Number(second.endTime)
+ );
+};
+
+/** Whether a picker value can be queried: a relative period, or a start that is before its end. */
+export const isUsablePickerValue = (value: any): boolean => {
+ if (!value) return false;
+ if (pickerValueType(value) === "relative") {
+ return RELATIVE_PERIOD_PATTERN.test(String(value.relativeTimePeriod ?? ""));
+ }
+
+ const start = Number(value.startTime);
+ const end = Number(value.endTime);
+ return Number.isFinite(start) && Number.isFinite(end) && start < end;
+};
+
+/** Resolves a picker value to the time object panels load with; a relative range resolves from now. */
+export const pickerValueToTimeObj = (value: any): { start_time: Date; end_time: Date } | null => {
+ if (!value) return null;
+
+ if (pickerValueType(value) === "relative") {
+ const window = value.relativeTimePeriod
+ ? getConsumableRelativeTime(value.relativeTimePeriod)
+ : undefined;
+ return window
+ ? { start_time: new Date(window.startTime), end_time: new Date(window.endTime) }
+ : null;
+ }
+
+ return {
+ start_time: new Date(Number(value.startTime)),
+ end_time: new Date(Number(value.endTime)),
+ };
+};
+
+/** The payload DateTime's `setSavedDate` takes for a picker value, absolute times as wall clock in `timezone`. */
+export const toPickerSavedDate = (value: any, timezone: string) => {
+ if (pickerValueType(value) === "relative") {
+ return { type: "relative", relativeTimePeriod: value.relativeTimePeriod };
+ }
+
+ // Wall-clock strings, because setSavedDate's timestamp path formats in the browser's zone, not the dashboard's.
+ const inTimezone = (micros: any, pattern: string) =>
+ timestampToTimezoneDate(Number(micros), timezone, pattern);
+ return {
+ type: "absolute",
+ selectedDate: {
+ from: inTimezone(value.startTime, "yyyy/MM/dd"),
+ to: inTimezone(value.endTime, "yyyy/MM/dd"),
+ },
+ selectedTime: {
+ startTime: inTimezone(value.startTime, "HH:mm:ss"),
+ endTime: inTimezone(value.endTime, "HH:mm:ss"),
+ },
+ };
+};
+
+/** Whether a dashboard panel runs on a relative range: URL pt-* time, then saved range, then global. */
+export const isEffectivePanelTimeRelative = (
+ panel: any,
+ query: LocationQuery | undefined,
+ globalTime: { valueType?: string } | null | undefined,
+): boolean => {
+ // A panel's own time counts only while panel time is on, as in ViewDashboard's computeAllPanelTimes.
+ const ownTime = hasPanelTime(panel)
+ ? (getPanelTimeFromURL(panel.id, query) ??
+ convertPanelTimeRangeToPicker(panel.config?.panel_time_range))
+ : null;
+ return (ownTime?.valueType ?? globalTime?.valueType) === "relative";
+};
+
/** Whether a dashboard URL change can move any panel's time; var-, pt- and cell_ params cannot. */
export const queryChangeAffectsPanelTimes = (
newQuery: Record,
diff --git a/web/src/views/Dashboards/RenderDashboardCharts.spec.ts b/web/src/views/Dashboards/RenderDashboardCharts.spec.ts
index 126ceaa3bf..20aac71bb7 100644
--- a/web/src/views/Dashboards/RenderDashboardCharts.spec.ts
+++ b/web/src/views/Dashboards/RenderDashboardCharts.spec.ts
@@ -18,6 +18,7 @@ import { flushPromises, shallowMount } from "@vue/test-utils";
import { installFakeIntersectionObserver } from "@/test/unit/helpers/intersectionObserverFake";
import { nextTick, ref } from "vue";
import RenderDashboardCharts from "./RenderDashboardCharts.vue";
+import DateTime from "@/components/DateTime.vue";
import { GridStack } from "gridstack";
import { updateDashboard } from "../../utils/commons";
import { LIVE_COMMIT_DEBOUNCE_MS } from "@/composables/dashboard/useVariablesManager";
@@ -738,6 +739,222 @@ describe("RenderDashboardCharts", () => {
});
});
+ describe("panel time pickers", () => {
+ let io: ReturnType;
+ let autoRun: ReturnType>;
+ let replaceSpy: any;
+
+ const PanelContainerStub = {
+ name: "PanelContainer",
+ props: ["data", "isTimeRangeWaiting"],
+ emits: ["refreshPanelRequest"],
+ template: '
',
+ };
+
+ const RELATIVE_HOUR = { type: "relative", relativeTimePeriod: "1h" };
+ const timedDashboard = (panelTimeRange: any = RELATIVE_HOUR) => ({
+ ...defaultProps.dashboardData,
+ tabs: [
+ {
+ tabId: "default",
+ name: "Default Tab",
+ panels: [
+ {
+ id: "P1",
+ title: "P1",
+ type: "line",
+ layout: { x: 0, y: 0, w: 6, h: 4, i: "P1" },
+ queries: [],
+ config: {
+ panel_time_enabled: true,
+ panel_time_range: panelTimeRange,
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ const mountWithPanelPicker = async (panelTimeRange?: any) => {
+ wrapper = shallowMount(RenderDashboardCharts, {
+ props: { ...defaultProps, dashboardData: timedDashboard(panelTimeRange) },
+ global: {
+ plugins: [i18n, store, router],
+ provide: { dashboardAutoRun: autoRun },
+ stubs: {
+ ODialog: ODialogStub,
+ PanelContainer: PanelContainerStub,
+ DateTimePickerDashboard: false,
+ DateTime: false,
+ },
+ },
+ });
+ await flushPromises();
+ await vi.waitFor(() => expect(wrapper.vm.shouldMountPanel("P1")).toBe(true));
+ await flushPromises();
+ };
+
+ const panel = () => wrapper.findComponent({ name: "PanelContainer" });
+ const dateTime = () => wrapper.findComponent({ name: "DateTimePickerDashboard" });
+ const pickPreset = async (unit: string, value: number) => {
+ dateTime().findComponent(DateTime).vm.setRelativeDate(unit, value);
+ await flushPromises();
+ };
+ const panelRefreshRequests = () =>
+ (wrapper.emitted("refreshPanelRequest") ?? []).filter((args: any[]) => args[0] === "P1");
+
+ beforeEach(() => {
+ io = installFakeIntersectionObserver({ autoVisible: true });
+ autoRun = ref(false);
+ store.state.timezone = "UTC";
+ replaceSpy = vi.spyOn(router, "replace").mockResolvedValue(undefined as any);
+ });
+
+ afterEach(() => {
+ replaceSpy.mockRestore();
+ io.restore();
+ });
+
+ it("renders the panel's own picker without an Apply button", async () => {
+ await mountWithPanelPicker();
+
+ expect(dateTime().props("autoApplyDashboard")).toBe(true);
+ expect(wrapper.vm.getPanelTimeGate("P1").applied.value.relativeTimePeriod).toBe("1h");
+ });
+
+ it("keeps a picked range waiting with Auto Run off and marks only that panel", async () => {
+ await mountWithPanelPicker();
+
+ await pickPreset("h", 6);
+
+ expect(wrapper.vm.getPanelTimeGate("P1").picked.value.relativeTimePeriod).toBe("6h");
+ expect(wrapper.vm.panelTimeValues.P1.relativeTimePeriod).toBe("1h");
+ expect(panel().props("isTimeRangeWaiting")).toBe(true);
+ expect(panelRefreshRequests()).toHaveLength(0);
+ expect(replaceSpy).not.toHaveBeenCalled();
+ });
+
+ it("applies the waiting range when the panel's own refresh is clicked", async () => {
+ await mountWithPanelPicker();
+ await pickPreset("h", 6);
+
+ panel().vm.$emit("refreshPanelRequest", "P1", false);
+ await flushPromises();
+
+ expect(wrapper.vm.panelTimeValues.P1.relativeTimePeriod).toBe("6h");
+ expect(replaceSpy).toHaveBeenCalledWith({
+ query: expect.objectContaining({ "pt-period.P1": "6h" }),
+ });
+ expect(panelRefreshRequests()).toEqual([["P1", false]]);
+ expect(panel().props("isTimeRangeWaiting")).toBe(false);
+ });
+
+ it("applies the waiting range on the dashboard Refresh", async () => {
+ await mountWithPanelPicker();
+ await pickPreset("h", 6);
+
+ await wrapper.vm.syncAllPanelDateTimePickers();
+ await flushPromises();
+
+ expect(wrapper.vm.panelTimeValues.P1.relativeTimePeriod).toBe("6h");
+ expect(panel().props("isTimeRangeWaiting")).toBe(false);
+ });
+
+ it("clears the marker without a reload when the applied range is picked again", async () => {
+ await mountWithPanelPicker();
+ await pickPreset("h", 6);
+
+ await pickPreset("h", 1);
+
+ expect(panel().props("isTimeRangeWaiting")).toBe(false);
+ expect(panelRefreshRequests()).toHaveLength(0);
+ expect(replaceSpy).not.toHaveBeenCalled();
+ });
+
+ it("applies a preset at once and refreshes only that panel when Auto Run is on", async () => {
+ autoRun.value = true;
+ await mountWithPanelPicker();
+
+ await pickPreset("h", 6);
+
+ expect(wrapper.vm.panelTimeValues.P1.relativeTimePeriod).toBe("6h");
+ expect(panelRefreshRequests()).toEqual([["P1", false]]);
+ expect(panel().props("isTimeRangeWaiting")).toBe(false);
+ });
+
+ it("applies a waiting range when Auto Run is turned on", async () => {
+ await mountWithPanelPicker();
+ await pickPreset("h", 6);
+
+ autoRun.value = true;
+ await flushPromises();
+
+ expect(wrapper.vm.panelTimeValues.P1.relativeTimePeriod).toBe("6h");
+ expect(panelRefreshRequests()).toEqual([["P1", false]]);
+ });
+
+ it.each([
+ ["off", false],
+ ["on", true],
+ ])(
+ "puts the panel picker back on the applied range when it closes on a broken range (Auto Run %s)",
+ async (_label, isOn) => {
+ autoRun.value = isOn;
+ await mountWithPanelPicker();
+ const realPicker = dateTime().findComponent(DateTime);
+
+ realPicker.vm.onMenuOpenChange(true);
+ realPicker.vm.selectedType = "absolute";
+ realPicker.vm.selectedDate = { from: "2026/10/13", to: "2026/10/13" };
+ realPicker.vm.selectedTime = { startTime: "18:50:30", endTime: "18:25:22" };
+ await flushPromises();
+ expect(realPicker.vm.getDisplayValue).toBe("2026/10/13 18:50:30 - 2026/10/13 18:25:22");
+
+ realPicker.vm.onMenuOpenChange(false);
+ await flushPromises();
+
+ expect(realPicker.vm.getDisplayValue).toBe("Past 1 Hour");
+ expect(wrapper.vm.getPanelTimeGate("P1").picked.value.relativeTimePeriod).toBe("1h");
+ expect(panel().props("isTimeRangeWaiting")).toBe(false);
+ expect(panelRefreshRequests()).toHaveLength(0);
+ expect(replaceSpy).not.toHaveBeenCalled();
+ },
+ );
+
+ it("puts an absolute panel range back in the picker when it closes on a broken one", async () => {
+ const start = Date.UTC(2026, 9, 13, 10, 0, 0) * 1000;
+ await mountWithPanelPicker({
+ type: "absolute",
+ startTime: start,
+ endTime: start + 30 * 60e6,
+ });
+ const realPicker = dateTime().findComponent(DateTime);
+ const appliedLabel = "2026/10/13 10:00:00 - 2026/10/13 10:30:00";
+ expect(realPicker.vm.getDisplayValue).toBe(appliedLabel);
+
+ realPicker.vm.onMenuOpenChange(true);
+ realPicker.vm.selectedTime = { startTime: "18:50:30", endTime: "18:25:22" };
+ await flushPromises();
+ expect(realPicker.vm.getDisplayValue).toBe("2026/10/13 18:50:30 - 2026/10/13 18:25:22");
+
+ realPicker.vm.onMenuOpenChange(false);
+ await flushPromises();
+
+ expect(realPicker.vm.getDisplayValue).toBe(appliedLabel);
+ expect(panel().props("isTimeRangeWaiting")).toBe(false);
+ expect(panelRefreshRequests()).toHaveLength(0);
+ expect(replaceSpy).not.toHaveBeenCalled();
+ });
+
+ it("does not reload the panel when its picker reports its starting range", async () => {
+ autoRun.value = true;
+ await mountWithPanelPicker();
+
+ expect(panelRefreshRequests()).toHaveLength(0);
+ expect(replaceSpy).not.toHaveBeenCalled();
+ });
+ });
+
describe("Tab Management", () => {
it("should render TabList when showTabs is true", () => {
wrapper = createWrapper({ showTabs: true });
diff --git a/web/src/views/Dashboards/RenderDashboardCharts.vue b/web/src/views/Dashboards/RenderDashboardCharts.vue
index 9e3db760f4..1dc8e83a8a 100644
--- a/web/src/views/Dashboards/RenderDashboardCharts.vue
+++ b/web/src/views/Dashboards/RenderDashboardCharts.vue
@@ -205,6 +205,7 @@ along with this program. If not, see .
"
:variablesData="getMergedVariablesForPanel(item.id)"
:currentVariablesData="getLiveVariablesForPanel(item.id)"
+ :isTimeRangeWaiting="isPanelTimeWaiting(item)"
:width="getPanelLayout(item, 'w')"
:height="getPanelLayout(item, 'h')"
:forceLoad="forceLoad"
@@ -242,17 +243,16 @@ along with this program. If not, see .
:data-test="`dashboard-panel-${item.id}-time-picker`"
>
onPanelTimeApply(item.id, val)"
+ @update:modelValue="(val) => getPanelTimeGate(item.id).onPick(val)"
+ @show="getPanelTimeGate(item.id).onShow()"
+ @hide="getPanelTimeGate(item.id).onHide()"
+ @timezone-change="getPanelTimeGate(item.id).flush()"
:data-test="`panel-time-picker-${item.id}`"
- :ref="
- (el) => {
- if (el) panelDateTimePickerRefs.set(item.id, el);
- }
- "
+ :ref="(picker) => setPanelTimePicker(item.id, picker)"
/>
@@ -321,6 +321,7 @@ along with this program. If not, see
.
import {
computed,
defineComponent,
+ effectScope,
onActivated,
onMounted,
onUnmounted,
@@ -347,11 +348,17 @@ import VariablesValueSelector from "../../components/dashboards/VariablesValueSe
import TabList from "@/components/dashboards/tabs/TabList.vue";
import useNotifications from "@/composables/useNotifications";
import { getVariableKey, useVariablesManager } from "@/composables/dashboard/useVariablesManager";
+import {
+ useDashboardTimeGate,
+ type DashboardTimeGate,
+} from "@/composables/dashboard/useDashboardTimeGate";
import { useLoading } from "@/composables/useLoading";
import { GridStack } from "gridstack";
import {
+ arePickerValuesEqual,
convertTimeObjToPickerFormat,
resolvePanelTimeValue,
+ toPickerSavedDate,
} from "@/utils/dashboard/panelTimeUtils";
import "gridstack/dist/gridstack.min.css";
import { panelDownloadRegistry, panelCsvRegistry } from "@/utils/panelDownloadRegistry";
@@ -1515,37 +1522,16 @@ export default defineComponent({
}
};
- // Track which panels are currently being synced to prevent infinite loops
- const panelsSyncingDateTime = ref(new Set