feat: load the latest data when a dashboard opens with Auto Run on, and apply time ranges without an Apply button (#15283)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
ktx-mihir
2026-10-15 08:53:39 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent 975a81b97b
commit 389de2a1b6
44 changed files with 3914 additions and 330 deletions
@@ -8668,6 +8668,8 @@
"query": "b80a37564fbb3c07"
},
"refresh": "0e91610117029a62",
"refreshToApplyTimeRange": "e404e3e3af5f63d3",
"refreshToApplyTimeRangeAndVariables": "eaf57930d373fb22",
"refreshToApplyVariableChanges": "60322c93e195602d",
"refreshToApplyVariables": "313f2c3169259c79",
"reloadDashboards": "a55d967a917d64d2",
@@ -9049,6 +9051,8 @@
},
"variables": "c41ce2cc0050e675",
"viewDashboard": {
"autoRunDescription": "98bc79a6b46bcff3",
"autoRunScope": "2dbe81022223af39",
"dashboard": "67b696468610b879",
"dashboardNotFound": "70cfe75710a014e9",
"failedToFetchReports": "79e14eb05d39e505",
@@ -95,9 +95,9 @@ export default class DashboardPanelTime {
await this.page.locator(`[data-test="date-time-relative-${timeRange}-btn"]`).click();
}
/** Click the date-time Apply button (page-level) */
/** Apply the range picked in the open date-time picker (Apply, or close + Refresh on dashboards) */
async clickDateTimeApplyBtn() {
await this.page.locator('[data-test="date-time-apply-btn"]').click();
await applyPickedDateTime(this.page);
}
/** Click the panel discard button (swallows errors; does not accept the dialog) */
@@ -345,7 +345,7 @@ export default class DashboardPanelTime {
* Change panel time in view mode (relative)
* @param {string} panelId - Panel ID
* @param {string} timeRange - e.g., "15-m", "1-h", "7-d"
* @param {boolean} clickApply - Whether to click Apply button (default: true)
* @param {boolean} clickApply - Whether to apply the pick (default: true); see applyPickedDateTime
* @param {number} [_attempt=1] - Internal recursion counter. Do NOT pass from call sites.
*/
async changePanelTimeInView(panelId, timeRange, clickApply = true, _attempt = 1) {
@@ -356,10 +356,12 @@ export default class DashboardPanelTime {
await this.dateTimeMenu.waitFor({ state: "visible", timeout: 5000 });
// Wait for all panel data API responses to complete BEFORE clicking the time
// option. This lets the panelsInitializing guard (500 ms timer) expire and
// reduces re-render churn around the Reka UI portal.
// option, which reduces re-render churn around the Reka UI portal.
await this._waitForAllPanelsIdle();
// Captured before the pick: with Auto Run on the pick applies, and moves the URL, at once.
const urlBefore = this.page.url();
// Page-level locators survive portal re-mounts; only one datetime menu can be
// open at a time (Reka UI unmounts when closed), so these are unambiguous.
const timeOptionLocator = this.page.locator(`[data-test="date-time-relative-${timeRange}-btn"]`).first();
@@ -371,44 +373,20 @@ export default class DashboardPanelTime {
await timeOptionLocator.click({ force: true, timeout: 15000 });
if (clickApply) {
// The time option selection itself may trigger a live-preview re-render or a
// new panel data request. Wait for those to settle so the Apply button is
// stable and the panelsInitializing guard (500 ms timer in
// RenderDashboardCharts) has had time to expire before we commit the change.
// With Auto Run on the pick already started a panel reload; let it settle first.
await this._waitForAllPanelsIdle();
// If the menu closed unexpectedly during the wait (a background re-render
// unmounted the portal), retry rather than hanging waiting for Apply.
const menuVisible = await this.dateTimeMenu.isVisible().catch(() => false);
if (!menuVisible) {
if (_attempt < 4) {
await this._waitForAllPanelsIdle();
return this.changePanelTimeInView(panelId, timeRange, clickApply, _attempt + 1);
}
throw new Error(
`changePanelTimeInView: date-time menu did not stay open for panel "${panelId}" after ${_attempt} attempts`
);
}
const applyBtn = this.page.locator('[data-test="date-time-apply-btn"]').first();
await applyBtn.waitFor({ state: "visible", timeout: 10000 });
const urlBefore = this.page.url();
try {
// No force:true — panels are idle so the button is stable and actionable.
// Explicit 10 s timeout: fail fast if the button detaches mid-click (another
// re-render) rather than hanging until the 45 s CI action-timeout.
await applyBtn.click({ timeout: 10000 });
// Panel pickers have no Apply: with Auto Run on the preset already applied; with it
// off the pick waits and the panel's own yellow refresh icon applies it.
await applyPickedDateTime(this.page);
// Wait for the dialog to close, then for the resulting panel data request
// (triggered by the new time range) to complete.
await this.dateTimeMenu.waitFor({ state: "hidden", timeout: 5000 });
await this._waitForAllPanelsIdle();
// Detect whether onPanelTimeApply was blocked by the panelsInitializing guard.
// The guard is a 500 ms timer that re-arms whenever initializePanelTimes()
// runs. If the URL hasn't changed the apply was a no-op — retry.
// An unchanged URL means the pick never reached the panel (a swallowed click) — retry.
const urlAfter = this.page.url();
if (urlAfter === urlBefore && _attempt < 4) {
await this._waitForAllPanelsIdle();
@@ -530,6 +508,7 @@ export default class DashboardPanelTime {
await timeOptionBtn.waitFor({ state: "visible", timeout: 5000 });
await timeOptionBtn.click();
// Apply the pick: the dashboard picker has no Apply button
await applyPickedDateTime(this.page);
// Wait for dialog to close and network to settle
@@ -630,8 +609,8 @@ export default class DashboardPanelTime {
}
/**
* Open global time picker and select a relative time WITHOUT clicking Apply
* Useful for testing that changes aren't applied without clicking Apply
* Open global time picker and select a relative time WITHOUT applying it
* On the Add/Edit panel page it waits for Apply; on a dashboard with Auto Run on a preset applies at once
* @param {string} timeRange - e.g., "6-d", "1-w", "15-m"
*/
async selectGlobalTimeWithoutApply(timeRange) {
@@ -651,7 +630,7 @@ export default class DashboardPanelTime {
}
/**
* Click the Apply button in an open date time picker
* Apply the range picked in an open date time picker (Apply, or close + Refresh on dashboards)
*/
async clickDateTimeApply() {
await applyPickedDateTime(this.page);
@@ -709,7 +688,7 @@ export default class DashboardPanelTime {
}
/**
* Change time in view panel mode (click picker, select relative time, click apply)
* Change time in view panel mode (click picker, select relative time, apply it)
* @param {string} timeRange - e.g., "6-d", "1-w"
*/
async changeViewPanelDateTime(timeRange) {
@@ -717,16 +696,15 @@ export default class DashboardPanelTime {
await this.dateTimeMenu.waitFor({ state: "visible", timeout: 5000 });
const timeOptionBtn = this.timeRangeBtn(timeRange);
await timeOptionBtn.waitFor({ state: "visible", timeout: 5000 });
await timeOptionBtn.click();
const applyBtn = this.dateTimeMenu.locator('[data-test="date-time-apply-btn"]');
// Set up the response waiter BEFORE clicking Apply, so we don't miss the triggered query
// Set up the response waiter BEFORE the pick, which applies at once with Auto Run on
const queryDone = this.page.waitForResponse(
r => r.url().includes('/_search') || r.url().includes('/query'),
{ timeout: 15000 }
).catch(() => {});
await applyBtn.click();
await timeOptionBtn.click();
await applyPickedDateTime(this.page);
await this.dateTimeMenu.waitFor({ state: "hidden", timeout: 5000 });
// Wait for the re-query response triggered by the time change
await queryDone;
@@ -16,7 +16,7 @@ export default class DashboardTimeRefresh {
this.absolutetimeTime = page.locator(
'[data-test="date-time-absolute-tab"]'
);
this.applyBtn = page.locator('[data-test="date-time-apply-btn"]');
this.dateTimeMenu = page.locator("#date-time-menu").first();
this.selectTime = page.locator('[data-test="date-time-relative-10-m-btn"]');
this.refreshBtnManual = page.locator(
'[data-test="logs-search-bar-refresh-interval-btn"]'
@@ -133,17 +133,18 @@ export default class DashboardTimeRefresh {
await endCell.waitFor({ state: "attached" });
await endCell.evaluate((el) => el.click());
// Apply the selection: Apply where the picker still has one, else close it and Apply or Refresh as needed.
await applyPickedDateTime(this.page);
// Wait for the date picker dropdown to fully close. If the dropdown
// remains open it overlays the panel's primary buttons (e.g.
// dashboard-apply) and downstream clicks time out.
try {
await this.applyBtn.waitFor({ state: "hidden", timeout: 5000 });
await this.dateTimeMenu.waitFor({ state: "hidden", timeout: 5000 });
} catch {
// Fallback: press Escape to dismiss any lingering portal.
await this.page.keyboard.press("Escape");
await this.applyBtn.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {});
await this.dateTimeMenu.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {});
}
}
@@ -7,7 +7,17 @@ export async function waitForDateTimeButtonToBeEnabled(page) {
await page.waitForSelector('[data-test="date-time-btn"]:not([disabled])', { timeout: 15000 });
}
// The panel editor's picker has no Apply of its own; with Auto Run off its range waits for the panel's Apply.
const WAITING_REFRESH_BUTTONS = [
'[data-test="dashboard-viewpanel-refresh-data-btn"][data-o2-variant="warning"]',
'[data-test="dashboard-panel-refresh-panel-btn"][data-o2-variant="ghost-warning"]',
'[data-test="dashboard-refresh-btn"][data-o2-variant="warning"]',
];
/**
* Applies the range just picked in the open date-time picker, with or without Auto Run.
* A picker that still has Apply gets it clicked. Otherwise the picker is closed (which applies with
* Auto Run on), then the panel editor's Apply runs it, or on a dashboard the Refresh it marked yellow.
*/
export async function applyPickedDateTime(page) {
const pickerApply = page.locator('[data-test="date-time-apply-btn"]');
if (await pickerApply.count()) {
@@ -16,9 +26,20 @@ export async function applyPickedDateTime(page) {
}
await page.keyboard.press("Escape");
await page.locator("#date-time-menu").first().waitFor({ state: "hidden", timeout: 5000 }).catch(() => {});
const panelApply = page.locator('[data-test="dashboard-apply"]').first();
if (!(await panelApply.count())) return;
await panelApply.click({ timeout: 10000 }).catch(() => panelApply.evaluate((el) => el.click()));
if (await panelApply.count()) {
await panelApply.click({ timeout: 10000 }).catch(() => panelApply.evaluate((el) => el.click()));
return;
}
for (const selector of WAITING_REFRESH_BUTTONS) {
const refreshBtn = page.locator(selector).first();
if (await refreshBtn.isVisible().catch(() => false)) {
await refreshBtn.evaluate((el) => el.click());
return;
}
}
}
export default class DateTimeHelper {
@@ -84,7 +84,7 @@ test.describe('Dashboard variables Auto Run', { tag: ['@dashboards', '@dashboard
const plainBase = await autoRun.settledRunCount('plain');
expect(autoRun.runsOf('var')[base - 1]).toContain(`'${initial}'`);
await autoRun.expectRefreshTooltip('Auto run enabled');
await autoRun.expectRefreshTooltip('Runs queries on open and on filter/time changes');
await autoRun.openRefreshOptions();
await autoRun.expectAutoRunToggleLabel('Turn off Auto Run');
await autoRun.closeMenu();
@@ -14,10 +14,14 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { ref } from "vue";
import { shallowMount, flushPromises } from "@vue/test-utils";
import { defineComponent, ref } from "vue";
import { mount, shallowMount, flushPromises } from "@vue/test-utils";
import DateTimePickerDashboard from "@/components/DateTimePickerDashboard.vue";
import DateTime from "@/components/DateTime.vue";
import { useDashboardTimeGate } from "@/composables/dashboard/useDashboardTimeGate";
import { toPickerSavedDate } from "@/utils/dashboard/panelTimeUtils";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
describe("DateTimePickerDashboard", () => {
let wrapper: any;
@@ -489,5 +493,107 @@ describe("DateTimePickerDashboard", () => {
expect(wrapper.vm.dateTimePicker).toBeDefined();
});
it("forwards a time zone change so a dashboard can apply it at once", () => {
wrapper = createWrapper();
wrapper.findComponent('[data-test="datetime-component"]').vm.$emit("on:timezone-change");
expect(wrapper.emitted("timezone-change")).toHaveLength(1);
});
});
describe("with the real picker behind a dashboard time gate", () => {
const Host = defineComponent({
components: { DateTimePickerDashboard },
props: {
autoApply: { type: Boolean, default: true },
initial: {
type: Object,
default: () => ({ valueType: "relative", relativeTimePeriod: "15m" }),
},
},
setup() {
const pickerRef = ref<any>(null);
const gate = useDashboardTimeGate({
isAutoRunOn: () => false,
showInPicker: (value) => pickerRef.value?.setSavedDate(toPickerSavedDate(value, "UTC")),
});
return { gate, pickerRef };
},
template: `<DateTimePickerDashboard
ref="pickerRef"
:model-value="gate.picked.value ?? initial"
:auto-apply-dashboard="autoApply"
@update:model-value="gate.onPick"
@show="gate.onShow"
@hide="gate.onHide"
/>`,
});
const openPicker = async (host: any) => {
host.findComponent(DateTime).vm.menuOpen = true;
await flushPromises();
};
const inMenu = (dataTest: string) =>
document.body.querySelector<HTMLElement>(`[data-test="${dataTest}"]`);
beforeEach(() => {
store.state.timezone = "UTC";
});
it("has no Apply button, and a preset stays picked until it is applied", async () => {
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [i18n, store] } });
await flushPromises();
const gate = (wrapper.vm as any).gate;
expect(gate.applied.value.relativeTimePeriod).toBe("15m");
await openPicker(wrapper);
expect(inMenu("date-time-apply-btn")).toBeNull();
inMenu("date-time-relative-1-h-btn")!.click();
await flushPromises();
expect(gate.picked.value.relativeTimePeriod).toBe("1h");
expect(gate.applied.value.relativeTimePeriod).toBe("15m");
expect(gate.needsRefresh.value).toBe(true);
expect(wrapper.find('[data-test="date-time-btn"]').text()).toContain("Past 1 Hour");
});
it("shows the applied range again after closing on a range that ends before it starts", async () => {
const start = Date.UTC(2026, 9, 13, 10, 0, 0) * 1000;
wrapper = mount(Host, {
props: { initial: { valueType: "absolute", startTime: start, endTime: start + 30 * 60e6 } },
attachTo: document.body,
global: { plugins: [i18n, store] },
});
await flushPromises();
const label = () => wrapper.find('[data-test="date-time-btn"]').text();
const realPicker = wrapper.findComponent(DateTime);
expect(label()).toContain("2026/10/13 10:00:00 - 2026/10/13 10:30:00");
realPicker.vm.onMenuOpenChange(true);
realPicker.vm.selectedTime = { startTime: "18:50:30", endTime: "18:25:22" };
await flushPromises();
expect(label()).toContain("2026/10/13 18:50:30 - 2026/10/13 18:25:22");
realPicker.vm.onMenuOpenChange(false);
await flushPromises();
expect(label()).toContain("2026/10/13 10:00:00 - 2026/10/13 10:30:00");
expect((wrapper.vm as any).gate.needsRefresh.value).toBe(false);
});
it("keeps the Apply button on a page that does not opt in", async () => {
wrapper = mount(Host, {
props: { autoApply: false },
attachTo: document.body,
global: { plugins: [i18n, store] },
});
await flushPromises();
await openPicker(wrapper);
expect(inMenu("date-time-apply-btn")).not.toBeNull();
});
});
});
@@ -24,6 +24,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
}"
:default-relative-time="modelValue.relativeTimePeriod"
@on:date-change="updateDateTime"
@on:timezone-change="onTimezoneChange"
:initialTimezone="initialTimezone"
:disable="disable"
:hide-relative-timezone="hideRelativeTimezone"
@@ -89,7 +90,7 @@ export default defineComponent({
type: String as PropType<ButtonVariant>,
},
},
emits: ["update:modelValue", "hide", "show"],
emits: ["update:modelValue", "hide", "show", "timezone-change"],
setup(props, { emit }) {
const dateTimePicker: any = ref(null);
@@ -133,6 +134,10 @@ export default defineComponent({
emit("show");
};
const onTimezoneChange = () => {
emit("timezone-change");
};
// Clean up resources on unmount to prevent memory leaks
onUnmounted(() => {
// If you add intervals, listeners, or subscriptions, clean them up here.
@@ -153,6 +158,7 @@ export default defineComponent({
setSavedDate,
onShow,
onHide,
onTimezoneChange,
};
},
});
@@ -16,6 +16,7 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import PanelContainer from "@/components/dashboards/PanelContainer.vue";
import PanelErrorButtons from "@/components/dashboards/PanelErrorButtons.vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
@@ -740,6 +741,51 @@ describe("PanelContainer", () => {
expect(wrapper.vm.variablesDataUpdated).toBe(true);
});
describe("a time range waiting in the panel's own picker", () => {
const refreshButton = () => wrapper.find('[data-test="dashboard-panel-refresh-panel-btn"]');
const refreshTooltip = () =>
refreshButton().findComponent({ name: "OTooltip" }).props("content");
it("marks the refresh icon with the variables warning look and says why", () => {
wrapper = createWrapper({ isTimeRangeWaiting: true });
expect(refreshButton().attributes("data-o2-variant")).toBe("ghost-warning");
expect(refreshTooltip()).toBe("Refresh to apply the time range");
});
it("names both when variables are waiting too", async () => {
wrapper = createWrapper({
isTimeRangeWaiting: true,
data: {
...mockPanelData,
queries: [{ query: "SELECT * FROM test WHERE field = ${testVar}" }],
},
variablesData: { values: [{ name: "testVar", value: "a" }], isLoading: false },
currentVariablesData: { values: [{ name: "testVar", value: "b" }], isLoading: false },
});
await wrapper.vm.$nextTick();
expect(refreshButton().attributes("data-o2-variant")).toBe("ghost-warning");
expect(refreshTooltip()).toBe("Refresh to apply the time range and variable changes");
});
it("keeps the marker outside layout edit mode, where the refresh icon is hidden", () => {
wrapper = createWrapper({ isTimeRangeWaiting: true, layoutMode: "view" });
expect(refreshButton().attributes("data-o2-variant")).toBe("ghost-warning");
wrapper.unmount();
wrapper = createWrapper({ isTimeRangeWaiting: true, layoutMode: "edit" });
expect(refreshButton().exists()).toBe(false);
});
it("keeps the plain refresh icon when nothing waits", () => {
wrapper = createWrapper({ isTimeRangeWaiting: false });
expect(refreshButton().attributes("data-o2-variant")).toBe("ghost");
expect(refreshTooltip()).toBe("Refresh");
});
});
});
describe("Delete Panel", () => {
@@ -1435,6 +1481,28 @@ describe("PanelContainer", () => {
expect(wrapper.vm.isPanelLoading).toBe(true);
});
it("keeps the previous last-refreshed age in the header while a restored panel refreshes", async () => {
wrapper = createWrapper({}, { PanelErrorButtons });
const renderer = wrapper.findComponent({ name: "PanelSchemaRenderer" });
const shownAge = () => {
expect(wrapper.find('[data-test="panel-last-refreshed-at"]').exists()).toBe(true);
return wrapper.findComponent(PanelErrorButtons).props("lastTriggeredAt");
};
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
await renderer.vm.$emit("last-triggered-at-update", tenMinutesAgo);
await renderer.vm.$emit("loading-state-change", true);
await flushPromises();
expect(wrapper.vm.isPanelLoading).toBe(true);
expect(shownAge()).toBe(tenMinutesAgo);
const freshAt = Date.now();
await renderer.vm.$emit("last-triggered-at-update", freshAt);
await renderer.vm.$emit("loading-state-change", false);
await flushPromises();
expect(shownAge()).toBe(freshAt);
});
});
describe("Metadata Management", () => {
@@ -234,7 +234,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<OButton
v-if="!viewOnly && !simplifiedPanelView && !isLayoutEditing"
:variant="variablesDataUpdated ? 'ghost-warning' : 'ghost'"
:variant="variablesDataUpdated || isTimeRangeWaiting ? 'ghost-warning' : 'ghost'"
size="icon"
@click="() => onRefreshPanel(false)"
:title="t('panel.refreshPanel')"
@@ -242,11 +242,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:disabled="isPanelLoading"
icon-left="refresh"
>
<OTooltip
:content="
variablesDataUpdated ? t('panel.refreshToApplyVariables') : t('panel.refresh')
"
/>
<OTooltip :content="refreshTooltip" />
</OButton>
<!-- simplifiedPanelView panels are ephemeral (Insights / Drill down): delete without a confirm -->
<OButton
@@ -581,6 +577,7 @@ export default defineComponent({
"simplifiedPanelView",
"shouldRefreshWithoutCache",
"showLegendsButton",
"isTimeRangeWaiting",
"layoutMode",
],
components: {
@@ -1050,6 +1047,15 @@ export default defineComponent({
});
});
const refreshTooltip = computed(() => {
if (props.isTimeRangeWaiting) {
return variablesDataUpdated.value
? t("dashboard.refreshToApplyTimeRangeAndVariables")
: t("dashboard.refreshToApplyTimeRange");
}
return variablesDataUpdated.value ? t("panel.refreshToApplyVariables") : t("panel.refresh");
});
const errorData = ref("");
const onError = (error: any) => {
if (typeof error === "string") {
@@ -1190,6 +1196,7 @@ export default defineComponent({
movePanelDialog,
onRefreshPanel,
variablesDataUpdated,
refreshTooltip,
onError,
errorData,
isPanelLoading,
@@ -1258,6 +1258,136 @@ describe("PanelSchemaRenderer", () => {
});
});
describe("Cache restore and the first load", () => {
describe("before the first load settles", () => {
const loaderWith = (overrides: Record<string, any>) => ({
data: ref([]),
loading: ref(false),
errorDetail: ref({ message: "", code: "" }),
metadata: ref({ queries: [] }),
resultMetaData: ref([]),
annotations: ref([]),
lastTriggeredAt: ref(null),
isCachedDataDifferWithCurrentTimeRange: ref(false),
searchRequestTraceIds: ref([]),
loadingProgressPercentage: ref(0),
isPartialData: ref(false),
isFirstLoadSettled: ref(false),
...overrides,
});
it("shows neither the chart nor No Data", async () => {
vi.mocked(usePanelDataLoader).mockReturnValueOnce(loaderWith({}) as any);
wrapper = createWrapper();
await flushPromises();
expect(wrapper.find('[data-test="no-data"]').exists()).toBe(false);
expect(wrapper.find('[data-test="chart-renderer"]').exists()).toBe(false);
});
it("keeps a table's empty footer hidden too", async () => {
vi.mocked(usePanelDataLoader).mockReturnValueOnce(loaderWith({}) as any);
wrapper = createWrapper({ panelSchema: { ...defaultProps.panelSchema, type: "table" } });
await flushPromises();
expect(wrapper.find('[data-test="table-renderer"]').exists()).toBe(false);
});
it("shows No Data once an empty first load settles", async () => {
const isFirstLoadSettled = ref(false);
vi.mocked(usePanelDataLoader).mockReturnValueOnce(
loaderWith({ isFirstLoadSettled }) as any,
);
wrapper = createWrapper();
await flushPromises();
isFirstLoadSettled.value = true;
await flushPromises();
expect(wrapper.find('[data-test="no-data"]').exists()).toBe(true);
});
it("renders the chart once the restored result settles", async () => {
const isFirstLoadSettled = ref(false);
const data = ref<any[]>([]);
vi.mocked(usePanelDataLoader).mockReturnValueOnce(
loaderWith({ isFirstLoadSettled, data }) as any,
);
wrapper = createWrapper({
panelSchema: {
...defaultProps.panelSchema,
queryType: "promql",
queries: [{ query: "up", fields: {}, config: {} }],
},
});
await flushPromises();
expect(convertPanelData).not.toHaveBeenCalled();
data.value = [{ resultType: "matrix", result: [{ metric: {}, values: [[1, "4"]] }] }];
isFirstLoadSettled.value = true;
await flushPromises();
expect(convertPanelData).toHaveBeenCalled();
expect(wrapper.find('[data-test="chart-renderer"]').exists()).toBe(true);
expect(wrapper.find('[data-test="no-data"]').exists()).toBe(false);
});
it("shows the error when the first load fails", async () => {
vi.mocked(usePanelDataLoader).mockReturnValueOnce(
loaderWith({
isFirstLoadSettled: ref(true),
errorDetail: ref({ message: "Query failed", code: 400 }),
}) as any,
);
wrapper = createWrapper();
await flushPromises();
expect(wrapper.find('[data-test="panel-schema-renderer-error-message"]').text()).toContain(
"Query failed",
);
});
it("never holds back a markdown panel", async () => {
vi.mocked(usePanelDataLoader).mockReturnValueOnce(loaderWith({}) as any);
wrapper = createWrapper({
panelSchema: { ...defaultProps.panelSchema, type: "markdown", queries: [] },
});
await flushPromises();
expect(wrapper.find('[data-test="markdown-renderer"]').exists()).toBe(true);
});
});
it("keeps the line loader on, over the restored chart, while a refresh loads", async () => {
vi.mocked(usePanelDataLoader).mockReturnValueOnce({
data: ref([{ resultType: "matrix", result: [{ metric: {}, values: [[1, "4"]] }] }]),
loading: ref(true),
errorDetail: ref({ message: "", code: "" }),
metadata: ref({ queries: [] }),
resultMetaData: ref([]),
annotations: ref([]),
lastTriggeredAt: ref(null),
isCachedDataDifferWithCurrentTimeRange: ref(false),
searchRequestTraceIds: ref([]),
loadingProgressPercentage: ref(0),
isPartialData: ref(false),
isFirstLoadSettled: ref(true),
} as any);
wrapper = createWrapper({
panelSchema: {
...defaultProps.panelSchema,
queryType: "promql",
queries: [{ query: "up", fields: {}, config: {} }],
},
});
await flushPromises();
expect(wrapper.find('[data-test="loading-progress"]').attributes("loading")).toBe("true");
expect(wrapper.find('[data-test="chart-renderer"]').exists()).toBe(true);
expect(wrapper.find('[data-test="no-data"]').exists()).toBe(false);
});
});
describe("Advanced Event Emissions", () => {
it("should emit series-data-update when panel data changes", async () => {
wrapper = createWrapper();
@@ -21,7 +21,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@mouseenter="showPopupsAndOverlays"
>
<div class="relative h-full" ref="chartPanelRef" :class="chartPanelClass">
<div v-if="!errorDetail?.message" :style="{ height: chartPanelHeight, width: '100%' }">
<div
v-if="!errorDetail?.message && !isAwaitingFirstLoad"
:style="{ height: chartPanelHeight, width: '100%' }"
>
<MapsRenderer
v-if="panelSchema.type == 'maps'"
:data="panelData.chartType == 'maps' ? panelData : { options: {} }"
@@ -158,6 +161,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OEmptyState
v-if="
noData &&
!isAwaitingFirstLoad &&
!errorDetail?.message &&
panelSchema.type != 'geomap' &&
panelSchema.type != 'maps' &&
@@ -779,6 +783,7 @@ export default defineComponent({
searchRequestTraceIds,
loadingProgressPercentage,
isPartialData,
isFirstLoadSettled,
reloadAnnotations,
} = usePanelDataLoader(
panelSchema,
@@ -803,6 +808,13 @@ export default defineComponent({
injectedPromqlData,
);
// Html and markdown render without a query, so they never wait on one.
const isAwaitingFirstLoad = computed(
() =>
isFirstLoadSettled?.value === false &&
!["html", "markdown"].includes(panelSchema.value?.type),
);
const {
isAddAnnotationMode,
isAddAnnotationDialogVisible,
@@ -1274,6 +1286,8 @@ export default defineComponent({
// Conversions can resolve out of order; only the most recently started may land.
let conversionGeneration = 0;
const convertPanelDataCommon = async (applyOverlay = false) => {
// 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,
@@ -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<any>;
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: '<div data-test="date-time-picker-dashboard"><slot /></div>',
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: '<span><slot name="trigger" /><slot /></span>' },
ODropdownItem: { template: '<span><slot name="icon-left" /><slot /></span>' },
},
);
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", () => {
@@ -43,12 +43,16 @@
/>
<DateTimePickerDashboard
v-model="selectedDate"
:model-value="pickedDate ?? selectedDate"
:auto-apply-dashboard="true"
ref="dateTimePickerRef"
class="hover:bg-interactive-hover-bg h-8 min-h-8 transition-all duration-200"
data-test="dashboard-viewpanel-date-time-picker"
:disable="disable"
@hide="setTimeForVariables()"
@update:model-value="timeGate.onPick"
@show="timeGate.onShow"
@hide="onTimePickerHide"
@timezone-change="timeGate.flush"
/>
<AutoRefreshInterval
v-model="refreshInterval"
@@ -78,15 +82,7 @@
data-test="dashboard-viewpanel-refresh-data-btn"
:icon-left="isAutoRunOn ? 'autorenew' : 'refresh'"
>
<OTooltip
:content="
isAutoRunOn
? t('search.autoRunEnabled')
: showApplyHint
? t('dashboard.refreshToApplyVariables')
: t('common.refresh')
"
/>
<OTooltip :content="refreshTooltip" />
</OButton>
<ODropdown v-if="isAutoRunAvailable" align="end" side="bottom">
<template #trigger>
@@ -125,7 +121,13 @@
{{ isAutoRunOn ? t("search.turnOffLiveMode") : t("search.turnOnLiveMode") }}
</div>
<div class="text-text-secondary text-xs">
{{ t("search.liveModeTooltip") }}
{{ t("dashboard.viewDashboard.autoRunDescription") }}
</div>
<div
class="text-text-secondary text-xs"
data-test="dashboard-viewpanel-auto-run-scope"
>
{{ t("dashboard.viewDashboard.autoRunScope") }}
</div>
</span>
</ODropdownItem>
@@ -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,
@@ -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 <http://www.gnu.org/licenses/>.
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<DashboardFreshOnOpen> =
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<string>();
return {
isRelativeRange,
appliesTo: (panel) => isAutoRunOn() && isRelativeRange(panel),
wasRefreshed: (key) => refreshed.has(key),
markRefreshed: (key) => {
refreshed.add(key);
},
reset: () => refreshed.clear(),
};
};
@@ -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 <http://www.gnu.org/licenses/>.
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<boolean | undefined>;
let onAutoApply: Mock<(value: unknown) => void>;
const createGate = (applied = ref<any>(null)) => {
const gate = scope.run(() =>
useDashboardTimeGate({ applied, isAutoRunOn: () => autoRun.value === true, onAutoApply }),
)!;
gate.onPick(START);
return gate;
};
beforeEach(() => {
vi.useFakeTimers();
scope = effectScope();
autoRun = ref<boolean | undefined>(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<any>({ 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<any>(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<typeof vi.fn>;
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<any>(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<any>(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();
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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<any>;
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<any>(null);
const picked = ref<any>(null);
let hasStartingRange = false;
let isPickerOpen = false;
let pendingApply: ReturnType<typeof setTimeout> | 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<typeof useDashboardTimeGate>;
@@ -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 <http://www.gnu.org/licenses/>.
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<string, any>(),
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<typeof import("@/utils/zincutils")>()),
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<string, any> = {}) => ({
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);
});
});
});
@@ -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,
};
@@ -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 <http://www.gnu.org/licenses/>.
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);
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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<HeldKey, any> & {
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 = <S extends PanelLoaderState>({
state,
panelSchema,
dashboardId,
}: {
state: S;
panelSchema: Ref<any>;
dashboardId?: Ref<any>;
}) => {
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<Pick<S, HeldKey> | 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<S, HeldKey>;
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<any>) =>
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<HeldKey, any>)[key] = value;
},
}),
]),
) as { [K in HeldKey]: WritableComputedRef<S[K]> };
return {
acceptsCachedWindow,
queueRefresh,
holdIfQueued,
release,
clearCacheUnlessHolding,
markIfComplete,
shownResult,
};
};
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+4
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
@@ -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 <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { isEffectivePanelTimeRelative } from "./panelTimeUtils";
const RELATIVE = { valueType: "relative" };
const ABSOLUTE = { valueType: "absolute" };
const panel = (config: Record<string, any> = {}) => ({ 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);
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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();
});
});
+87
View File
@@ -14,6 +14,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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<string, any>,
@@ -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<typeof installFakeIntersectionObserver>;
let autoRun: ReturnType<typeof ref<boolean>>;
let replaceSpy: any;
const PanelContainerStub = {
name: "PanelContainer",
props: ["data", "isTimeRangeWaiting"],
emits: ["refreshPanelRequest"],
template: '<div data-test="panel-container-stub"><slot name="panel-variables" /></div>',
};
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 });
@@ -205,6 +205,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
: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 <http://www.gnu.org/licenses/>.
:data-test="`dashboard-panel-${item.id}-time-picker`"
>
<DateTimePickerDashboard
:modelValue="panelTimeValues[item.id]"
:auto-apply-dashboard="false"
:modelValue="getPanelPickedTime(item.id) ?? panelTimeValues[item.id]"
:auto-apply-dashboard="true"
size="sm"
class="panel-time-picker-widget"
@update:modelValue="(val) => 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)"
/>
</div>
@@ -321,6 +321,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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<string>());
// A panel's waiting range is applied here: by its own refresh, the dashboard Refresh, or Auto Run.
const syncPanelDateTimePickerState = (panelId: string) => {
const pickerRef = panelDateTimePickerRefs.get(panelId);
const gate = panelTimeGates.get(panelId);
if (!gate) return;
if (!pickerRef?.dateTimePicker) {
return;
}
gate.apply();
// Mark this panel as currently syncing
panelsSyncingDateTime.value.add(panelId);
try {
// Call refresh() to commit uncommitted changes
pickerRef.refresh();
// Get the committed value
const innerDateTimePicker = pickerRef.dateTimePicker;
const currentDateTime = innerDateTimePicker.getConsumableDateTime();
if (currentDateTime) {
if (!arePickerValuesEqual(panelTimeValues.value[panelId], currentDateTime)) {
panelTimeValues.value[panelId] = currentDateTime;
}
}
} finally {
// Unmark after a short delay to allow events to settle
setTimeout(() => {
panelsSyncingDateTime.value.delete(panelId);
}, 100);
const appliedTime = gate.applied.value;
if (appliedTime && !arePickerValuesEqual(panelTimeValues.value[panelId], appliedTime)) {
panelTimeValues.value[panelId] = appliedTime;
}
};
@@ -1623,23 +1609,51 @@ export default defineComponent({
};
// ===== Panel Time Configuration (NEW FEATURE) =====
// Panel time values for all panels (map: panelId -> time value)
// The applied time of every panel that has its own picker (map: panelId -> time value)
const panelTimeValues = ref<Record<string, any>>({});
// Store refs to panel datetime picker components using a Map (not reactive)
const panelDateTimePickerRefs = new Map<string, any>();
// Gates outlive a picker remount, so a range still waiting survives a tab switch.
const panelTimeGates = new Map<string, DashboardTimeGate>();
const panelTimeGateScope = effectScope(true);
onBeforeUnmount(() => panelTimeGateScope.stop());
const panelTimePickers = new Map<string, any>();
const setPanelTimePicker = (panelId: string, picker: any) => {
if (picker) panelTimePickers.set(panelId, picker);
else panelTimePickers.delete(panelId);
};
const getPanelTimeGate = (panelId: string) => {
let gate = panelTimeGates.get(panelId);
if (!gate) {
gate = panelTimeGateScope.run(() =>
useDashboardTimeGate({
isAutoRunOn: () => dashboardAutoRun.value === true,
onAutoApply: () => refreshPanelRequest(panelId, false),
showInPicker: (value) =>
panelTimePickers
.get(panelId)
?.setSavedDate(toPickerSavedDate(value, store.state.timezone)),
}),
);
panelTimeGates.set(panelId, gate);
}
return gate;
};
const getPanelPickedTime = (panelId: string) => getPanelTimeGate(panelId).picked.value;
// panelDownloadRegistry is a module-level singleton (see utils/panelDownloadRegistry.ts).
// PanelContainer instances register themselves on mount; no provide/inject needed.
// Track panels that are initializing (to prevent spurious change events)
const panelsInitializing = ref<Set<string>>(new Set());
// Check if a specific panel has time enabled
const hasPanelTime = (panel: any) => {
return !!panel?.config?.panel_time_enabled;
};
const isPanelTimeWaiting = (panel: any) =>
hasPanelTime(panel) && getPanelTimeGate(panel.id).needsRefresh.value;
// Computed property to get the correct time for view panel
// Returns panel-specific time if available, otherwise returns global time
const viewPanelSelectedDate = computed(() => {
@@ -1658,25 +1672,6 @@ export default defineComponent({
return props.selectedDateForViewPanel;
});
// Semantic equality for picker values — used in both initializePanelTimes and onPanelTimeApply.
// DateTimePickerDashboard normalizes values (adds startTime/endTime, may drop "type"),
// so we compare semantically: for relative, only compare the period string.
const arePickerValuesEqual = (v1: any, v2: any) => {
if (!v1 || !v2) return v1 === v2;
const type1 = v1.valueType || v1.type;
const type2 = v2.valueType || v2.type;
if (type1 !== type2) return false;
// For relative: only the period matters (startTime/endTime are computed & change over time)
if (type1 === "relative") {
return v1.relativeTimePeriod === v2.relativeTimePeriod;
}
// For absolute: compare start and end times
return v1.startTime === v2.startTime && v1.endTime === v2.endTime;
};
// Initialize panel time values for panels with panel-level time enabled
const initializePanelTimes = () => {
panels.value?.forEach((panel: any) => {
@@ -1684,8 +1679,7 @@ export default defineComponent({
if (hasPanelTime(panel)) {
const panelId = panel.id;
// Mark this panel as initializing to prevent change events
panelsInitializing.value.add(panelId);
getPanelTimeGate(panelId);
// When panel has no custom time (panel_time_range is null) and no URL panel params,
// use local convertGlobalTimeToPickerFormat which preserves relative/absolute type
@@ -1708,9 +1702,6 @@ export default defineComponent({
if (pickerValue && !arePickerValuesEqual(panelTimeValues.value[panelId], pickerValue)) {
panelTimeValues.value[panelId] = pickerValue;
}
// Cleanup initialization flag
setTimeout(() => panelsInitializing.value.delete(panelId), 500);
}
});
};
@@ -1749,34 +1740,6 @@ export default defineComponent({
};
};
// Handle Apply button click on panel time picker
const onPanelTimeApply = async (panelId: string, newValue?: any) => {
// Guard against infinite recursion during state synchronization
if (panelsSyncingDateTime.value.has(panelId)) {
return;
}
// Guard against firing during initialization (when initializePanelTimes sets values)
// This prevents null-config panels from creating spurious URL params
if (panelsInitializing.value.has(panelId)) {
return;
}
// Skip when DateTime.vue emits its current value on mount (open-picker cascade).
// We use :modelValue (not v-model) so panelTimeValues is NOT auto-written before
// this handler runs — enabling a true before/after equality check here.
if (newValue && arePickerValuesEqual(panelTimeValues.value[panelId], newValue)) {
return;
}
if (newValue) {
panelTimeValues.value[panelId] = newValue;
}
// Use the local helper which handles state syncing, URL update and variable freeze for this panel
await refreshPanelRequest(panelId, false);
};
// Update URL with panel time params
const updateURLWithPanelTime = async (panelId: string, timeValue: any) => {
// Get the latest live variable params to ensure we don't lose uncommitted changes
@@ -1913,8 +1876,10 @@ export default defineComponent({
// Panel time configuration (NEW)
panelTimeValues,
hasPanelTime,
onPanelTimeApply,
panelDateTimePickerRefs,
getPanelTimeGate,
getPanelPickedTime,
isPanelTimeWaiting,
setPanelTimePicker,
viewPanelSelectedDate,
};
},
+113 -81
View File
@@ -98,10 +98,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="dateTimePicker"
class="dashboard-icons h-7.5 [transition:all_0.2s_ease] max-md:[&_.date-time-label]:hidden"
size="sm"
v-model="selectedDate"
:modelValue="pickedDate ?? selectedDate"
:auto-apply-dashboard="true"
:initialTimezone="initialTimezone"
:disable="arePanelsLoading"
@hide="setTimeForVariables"
@update:modelValue="timeGate.onPick"
@show="timeGate.onShow"
@hide="onTimePickerHide"
@timezone-change="timeGate.flush"
data-test="dashboard-global-date-time-picker"
/>
<AutoRefreshInterval
@@ -126,7 +130,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<OButton
v-else
:variant="isVariablesChanged ? 'warning' : 'outline'"
:variant="isRefreshPending ? 'warning' : 'outline'"
size="icon-toolbar"
@click="refreshData()"
:disabled="arePanelsLoading"
@@ -134,16 +138,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="dashboard-refresh-btn"
:icon-left="isAutoRunOn ? 'autorenew' : 'refresh'"
>
<OTooltip
:content="
isAutoRunOn
? t('search.autoRunEnabled')
: isVariablesChanged
? t('dashboard.viewDashboard.refreshToApplyVariables')
: t('dashboard.viewDashboard.refresh')
"
shortcut-id="dashboardRefresh"
/>
<OTooltip :content="refreshTooltip" shortcut-id="dashboardRefresh" />
</OButton>
<ODropdown align="end" side="bottom">
<template #trigger>
@@ -151,7 +146,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:variant="refreshOptionsVariant"
size="icon-toolbar"
class="w-5"
:disabled="arePanelsLoading"
:aria-label="t('dashboard.viewDashboard.moreRefreshOptions')"
data-test="dashboard-refresh-options-btn"
icon-left="arrow-drop-down"
@@ -160,6 +154,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<ODropdownItem
data-test="dashboard-refresh-without-cache-btn"
icon-left="cached"
:disabled="arePanelsLoading"
@select="refreshData(true)"
>
{{ t("dashboard.viewDashboard.refreshCacheReload") }}
@@ -179,7 +174,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ isAutoRunOn ? t("search.turnOffLiveMode") : t("search.turnOnLiveMode") }}
</div>
<div class="text-text-secondary text-xs">
{{ t("search.liveModeTooltip") }}
{{ t("dashboard.viewDashboard.autoRunDescription") }}
</div>
<div class="text-text-secondary text-xs" data-test="dashboard-auto-run-scope">
{{ t("dashboard.viewDashboard.autoRunScope") }}
</div>
</span>
</ODropdownItem>
@@ -546,7 +544,7 @@ import analytics from "@/services/product_analytics";
import DateTimePickerDashboard from "@/components/DateTimePickerDashboard.vue";
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from "vue-router";
import { getDashboard, movePanelToAnotherTab, getFoldersList } from "../../utils/commons.ts";
import { parseDuration, generateDurationLabel, getConsumableRelativeTime } from "../../utils/date";
import { parseDuration, generateDurationLabel } from "../../utils/date";
import { useRoute } from "vue-router";
import { useListBackNavigation } from "@/composables/useListBackNavigation";
import { deletePanel } from "../../utils/commons";
@@ -558,15 +556,17 @@ import AutoRefreshInterval from "@/components/AutoRefreshInterval.vue";
import ExportDashboard from "@/components/dashboards/ExportDashboard.vue";
import RenderDashboardCharts from "./RenderDashboardCharts.vue";
import useNotifications from "@/composables/useNotifications";
import {
DASHBOARD_FRESH_PANEL_TIME,
resolveAppliedRangeNow,
} from "@/composables/dashboard/dashboardFreshPanelTime";
import { DASHBOARD_FRESH_PANEL_TIME } from "@/composables/dashboard/dashboardFreshPanelTime";
import { useHomeDashboard } from "@/composables/useHomeDashboard";
import reports from "@/services/reports";
import config from "@/aws-exports";
import useCancelQuery from "@/composables/dashboard/useCancelQuery";
import { useAutoRunToggle } from "@/composables/dashboard/useAutoRunToggle";
import { useDashboardTimeGate } from "@/composables/dashboard/useDashboardTimeGate";
import {
createDashboardFreshOnOpen,
DASHBOARD_FRESH_ON_OPEN,
} from "@/composables/dashboard/dashboardFreshOnOpen";
import PanelLayoutSettings from "./PanelLayoutSettings.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
@@ -585,7 +585,14 @@ import { isEqual } from "lodash-es";
import { panelIdToBeRefreshed } from "@/utils/dashboard/convertCustomChartData";
import { getUUID } from "@/utils/zincutils";
import { createDashboardsContextProvider, contextRegistry } from "@/composables/contextProviders";
import { hasPanelTime, queryChangeAffectsPanelTimes } from "@/utils/dashboard/panelTimeUtils";
import {
arePickerValuesEqual,
hasPanelTime,
isEffectivePanelTimeRelative,
pickerValueToTimeObj,
queryChangeAffectsPanelTimes,
toPickerSavedDate,
} from "@/utils/dashboard/panelTimeUtils";
import { useAiDashboardEvents } from "@/composables/useAiDashboardEvents";
import type { AiDashboardEvent } from "@/composables/useAiDashboardEvents";
import {
@@ -730,26 +737,23 @@ export default defineComponent({
relativeTimePeriod: params.period ? params.period : "15m",
});
const setTimeForVariables = () => {
if (selectedDate.value && dateTimePicker.value) {
const date = dateTimePicker.value?.getConsumableDateTime();
const startTime = new Date(date.startTime);
const endTime = new Date(date.endTime);
// Everything that loads data reads this, never the live picker: a range still waiting must not reach a panel.
const getAppliedGlobalTime = () => pickerValueToTimeObj(selectedDate.value);
const setTimeForVariables = () => {
const appliedTime = getAppliedGlobalTime();
if (appliedTime && dateTimePicker.value) {
// Update only the variables time object
currentTimeObjPerPanel.value = {
...currentTimeObjPerPanel.value,
__variables: {
start_time: startTime,
end_time: endTime,
},
__variables: appliedTime,
};
}
};
const dateTimePicker = ref(null); // holds a reference to the date time picker
// holds the date picker v-modal
// The applied range: what panels, the URL and the Share link use. The picker's own value is `pickedDate`.
const selectedDate = ref(null);
// holds the current time for the dashboard
@@ -800,6 +804,32 @@ export default defineComponent({
const { isAutoRunAvailable, isAutoRunOn, toggleAutoRun } = useAutoRunToggle();
provide("dashboardAutoRun", isAutoRunOn);
const showRangeInPicker = (value: any) =>
dateTimePicker.value?.setSavedDate(toPickerSavedDate(value, store.state.timezone));
const timeGate = useDashboardTimeGate({
applied: selectedDate,
isAutoRunOn: () => isAutoRunOn.value,
showInPicker: showRangeInPicker,
});
const onTimePickerHide = () => {
timeGate.onHide();
setTimeForVariables();
};
const freshOnOpen = createDashboardFreshOnOpen({
isAutoRunOn: () => isAutoRunOn.value,
isRelativeRange: (panel) =>
isEffectivePanelTimeRelative(panel, route.query, selectedDate.value),
});
provide(DASHBOARD_FRESH_ON_OPEN, freshOnOpen);
// A cross-dashboard drilldown keeps this view mounted, so each dashboard it opens is a new visit.
watch(
() => route.query.dashboard,
() => freshOnOpen.reset(),
);
// variables data
const variablesData = reactive({});
const refreshedVariablesData = reactive({}); // Flag to track if variables have changed
@@ -882,6 +912,22 @@ export default defineComponent({
return !isEqual(normalizedCurrent, normalizedRefreshed);
});
const isRefreshPending = computed(
() => isVariablesChanged.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.viewDashboard.refreshToApplyVariables")
: t("dashboard.viewDashboard.refresh");
});
// ======= [START] default variable values
const initialVariableValues = reactive({
@@ -1015,6 +1061,8 @@ export default defineComponent({
refreshedVariablesData.values = [];
}
const previousAppliedDate = selectedDate.value;
// check if route has time related query params
// if not, take dashboard default time settings
if (!((route.query.from && route.query.to) || route.query.period)) {
@@ -1039,6 +1087,18 @@ export default defineComponent({
// take route time related query params
selectedDate.value = getSelectedDateFromQueryParams(route.query);
}
if (previousAppliedDate && !arePickerValuesEqual(previousAppliedDate, selectedDate.value)) {
showAppliedDateInPicker();
}
};
// The mounted picker reads its v-model only once, so a range applied by another dashboard's URL is pushed in.
const showAppliedDateInPicker = () => {
if (!dateTimePicker.value || !selectedDate.value) return;
timeGate.adopt();
showRangeInPicker(selectedDate.value);
};
// [START] cancel running queries
@@ -1188,28 +1248,6 @@ export default defineComponent({
// ===== Panel Time Configuration =====
// Helper: Convert picker format to time object
const convertPickerToTimeObj = (pickerValue: any) => {
if (!pickerValue) return null;
if (pickerValue.valueType === "relative" && pickerValue.relativeTimePeriod) {
const result = getConsumableRelativeTime(pickerValue.relativeTimePeriod);
if (result) {
return {
start_time: new Date(result.startTime),
end_time: new Date(result.endTime),
};
}
} else if (pickerValue.valueType === "absolute") {
return {
start_time: new Date(pickerValue.startTime),
end_time: new Date(pickerValue.endTime),
};
}
return null;
};
// Compute effective time for a specific panel
// Priority: 1. URL params (highest) → 2. panel_time_range → 3. global time AS-IS
const computePanelTime = (panel: any, globalTime: any) => {
@@ -1219,14 +1257,14 @@ export default defineComponent({
// URL params always win, even if panel_time_range is null
const urlPanelTime = getPanelTimeFromURL(panel.id, route.query);
if (urlPanelTime) {
return convertPickerToTimeObj(urlPanelTime);
return pickerValueToTimeObj(urlPanelTime);
}
// Priority 2: Use panel's configured time range (if set)
if (panel.config?.panel_time_range) {
const pickerValue = convertPanelTimeRangeToPicker(panel.config.panel_time_range);
if (pickerValue) {
return convertPickerToTimeObj(pickerValue);
return pickerValueToTimeObj(pickerValue);
}
}
@@ -1250,15 +1288,11 @@ export default defineComponent({
}
}
if (!targetPanel) {
const globalTime = getAppliedGlobalTime();
if (!targetPanel || !globalTime) {
return;
}
const globalTime = {
start_time: new Date(dateTimePicker.value.getConsumableDateTime().startTime),
end_time: new Date(dateTimePicker.value.getConsumableDateTime().endTime),
};
// Check if panel has its own time configuration
if (hasPanelTime(targetPanel)) {
// Panel has panel-level time - only update this specific panel's time
@@ -1282,7 +1316,7 @@ export default defineComponent({
const panel = currentDashboardData.data?.tabs
?.flatMap((tab: any) => tab.panels ?? [])
.find((p: any) => p.id === panelId);
const globalTime = panel && resolveAppliedRangeNow(selectedDate.value);
const globalTime = panel && getAppliedGlobalTime();
if (!globalTime) return undefined;
return hasPanelTime(panel) ? computePanelTime(panel, globalTime) : globalTime;
});
@@ -1304,15 +1338,11 @@ export default defineComponent({
// Compute times for all panels in all tabs
// @param forceRefresh - If true, always create new time objects to force all panels to refresh
const computeAllPanelTimes = (forceRefresh = false) => {
if (!currentDashboardData.data?.tabs || !dateTimePicker.value) {
const globalTime = getAppliedGlobalTime();
if (!currentDashboardData.data?.tabs || !dateTimePicker.value || !globalTime) {
return;
}
const globalTime = {
start_time: new Date(dateTimePicker.value.getConsumableDateTime().startTime),
end_time: new Date(dateTimePicker.value.getConsumableDateTime().endTime),
};
// A non-forced recompute must not advance a relative range: its resolved now drifts a few hundred ms between calls during load, refiring every global panel's time watcher (cache paint → spurious refetch).
const existingGlobalTime = currentTimeObjPerPanel.value.__global;
const isRelativeGlobal = selectedDate.value?.valueType === "relative";
@@ -1371,19 +1401,15 @@ export default defineComponent({
});
};
// when the date changes from the picker, update the current time object for the dashboard
// when the applied range changes, update the current time object for the dashboard
watch(selectedDate, async () => {
if (selectedDate.value && dateTimePicker.value) {
const appliedTime = getAppliedGlobalTime();
if (appliedTime && dateTimePicker.value) {
// CRITICAL: Clear panelIdToBeRefreshed to ensure all panels refresh
// When global date time changes, all panels should update
panelIdToBeRefreshed.value = null;
const date = dateTimePicker.value?.getConsumableDateTime();
currentTimeObj.value = {
start_time: new Date(date.startTime),
end_time: new Date(date.endTime),
};
currentTimeObj.value = appliedTime;
// DON'T reset currentTimeObjPerPanel here - let computeAllPanelTimes() handle it
// This prevents losing panel-specific times during the update
@@ -1585,7 +1611,7 @@ export default defineComponent({
const refreshOptionsVariant = computed(() => {
if (config.isEnterprise == "true" && arePanelsLoading.value) return "outline-destructive";
return isVariablesChanged.value ? "warning" : "outline";
return isRefreshPending.value ? "warning" : "outline";
});
const refreshData = async (withoutCache = false) => {
@@ -1614,15 +1640,15 @@ export default defineComponent({
// Sync panel-level datetime pickers FIRST (updates URL)
await renderDashboardChartsRef.value?.syncAllPanelDateTimePickers();
// Applied before the recompute below, which reads the applied range and not the picker.
timeGate.refresh();
// Recompute all panel times after syncing
// Pass forceRefresh=true to ensure ALL panels refresh (create new time objects)
computeAllPanelTimes(true);
// Commit all live variable changes to committed state
renderDashboardChartsRef.value?.commitAllVariables();
// Refresh the dashboard (syncs global datetime picker)
dateTimePicker.value.refresh();
}
};
@@ -1644,8 +1670,9 @@ export default defineComponent({
// set it as a absolute time
dateTimePicker?.value?.setCustomDate("absolute", selectedDateObj);
// refresh dashboard
// The picker reports the zoomed range here; a zoom applies at once even while Auto Run is off.
dateTimePicker.value.refresh();
timeGate.refresh();
};
// ------- work with query params ----------
@@ -2255,6 +2282,11 @@ export default defineComponent({
toggleAutoRun,
refreshOptionsVariant,
isVariablesChanged,
isRefreshPending,
refreshTooltip,
timeGate,
pickedDate: timeGate.picked,
onTimePickerHide,
refreshedVariablesDataUpdated,
onDeletePanel,
variablesData,
@@ -15,6 +15,8 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { shallowMount, flushPromises } from "@vue/test-utils";
import { inject } from "vue";
import { DASHBOARD_FRESH_ON_OPEN } from "@/composables/dashboard/dashboardFreshOnOpen";
// Comprehensive service mocks - these prevent real API calls
vi.mock("@/services/dashboards", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
@@ -251,6 +253,7 @@ vi.mock("moment-timezone", () => ({
import ViewDashboard from "@/views/Dashboards/ViewDashboard.vue";
import i18n from "@/locales";
import enUS from "@/locales/languages/en-US.json";
import store from "@/test/unit/helpers/store";
import analytics from "@/services/product_analytics";
import ShareButton from "@/components/common/ShareButton.vue";
@@ -496,6 +499,166 @@ describe("ViewDashboard", () => {
expect(localStorage.getItem("oo_toggle_auto_run")).toBe("false");
expect(wrapper.vm.isAutoRunOn).toBe(false);
});
describe("on open", () => {
let freshOnOpen: any;
const mountWithToolbar = () =>
createWrapper({
global: {
plugins: [i18n, store],
renderStubDefaultSlot: true,
stubs: {
OPageLayout: {
setup() {
freshOnOpen = inject(DASHBOARD_FRESH_ON_OPEN, null);
},
template: '<div><slot name="actions" /></div>',
},
ODropdown: { template: '<div><slot name="trigger" /><slot /></div>' },
},
},
});
const relativePanel = {
id: "P1",
config: {
panel_time_enabled: true,
panel_time_range: { type: "relative", relativeTimePeriod: "1h" },
},
};
beforeEach(() => {
freshOnOpen = null;
});
it("keeps the refresh options reachable while panels load", async () => {
setAutoQuery(true);
wrapper = mountWithToolbar();
await flushPromises();
wrapper.vm.handleEmittedData(false);
await flushPromises();
const option = (dataTest: string) => wrapper.findComponent(`[data-test="${dataTest}"]`);
expect(wrapper.vm.arePanelsLoading).toBe(true);
expect(option("dashboard-refresh-options-btn").props("disabled")).toBe(false);
expect(option("dashboard-refresh-without-cache-btn").props("disabled")).toBe(true);
expect(option("dashboard-auto-run-toggle-btn").props("disabled")).toBe(false);
});
it("enables Refresh cache & reload again once panels finish", async () => {
setAutoQuery(true);
wrapper = mountWithToolbar();
await flushPromises();
wrapper.vm.handleEmittedData(false);
wrapper.vm.handleEmittedData(true);
await flushPromises();
expect(
wrapper
.findComponent('[data-test="dashboard-refresh-without-cache-btn"]')
.props("disabled"),
).toBe(false);
});
it("describes Auto Run on dashboards with the dashboard-only copy", async () => {
setAutoQuery(true);
wrapper = mountWithToolbar();
await flushPromises();
const toggle = wrapper.find('[data-test="dashboard-auto-run-toggle-btn"]');
expect(toggle.text()).toContain("dashboard.viewDashboard.autoRunDescription");
expect(toggle.text()).not.toContain("search.liveModeTooltip");
const tooltips = wrapper
.findAllComponents({ name: "OTooltip" })
.map((tooltip: any) => tooltip.props("content"));
expect(tooltips).toContain("dashboard.viewDashboard.autoRunDescription");
expect(tooltips).not.toContain("dashboard.viewDashboard.autoRunScope");
expect(tooltips).not.toContain("search.autoRunEnabled");
});
it("says below the description that Auto Run applies beyond this dashboard", async () => {
setAutoQuery(true);
wrapper = mountWithToolbar();
await flushPromises();
const toggle = wrapper.find('[data-test="dashboard-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-auto-run-scope"]').classes()).toEqual(
expect.arrayContaining(["text-text-secondary", "text-xs"]),
);
});
it("keeps the scope line whether Auto Run is on or off", async () => {
setAutoQuery(true);
wrapper = mountWithToolbar();
await flushPromises();
const toggle = () => wrapper.find('[data-test="dashboard-auto-run-toggle-btn"]');
const scope = () => toggle().find('[data-test="dashboard-auto-run-scope"]');
expect(toggle().text()).toContain("search.turnOffLiveMode");
expect(scope().text()).toBe("dashboard.viewDashboard.autoRunScope");
wrapper.vm.toggleAutoRun();
await flushPromises();
expect(toggle().text()).toContain("search.turnOnLiveMode");
expect(scope().text()).toBe("dashboard.viewDashboard.autoRunScope");
});
it("ships the approved English copy for both lines", () => {
expect(enUS.dashboard.viewDashboard.autoRunDescription).toBe(
"Runs queries on open and on filter/time changes",
);
expect(enUS.dashboard.viewDashboard.autoRunScope).toBe(
"Applies to all dashboards, Logs and Traces",
);
});
it("provides a gate that needs Auto Run and a relative effective range", async () => {
setAutoQuery(true);
wrapper = mountWithToolbar();
await flushPromises();
const globalPanel = { id: "P2", config: {} };
wrapper.vm.selectedDate = { valueType: "relative", relativeTimePeriod: "15m" };
expect(freshOnOpen.appliesTo(globalPanel)).toBe(true);
wrapper.vm.selectedDate = { valueType: "absolute", startTime: 1, endTime: 2 };
expect(freshOnOpen.appliesTo(globalPanel)).toBe(false);
expect(freshOnOpen.appliesTo(relativePanel)).toBe(true);
wrapper.vm.toggleAutoRun();
expect(freshOnOpen.appliesTo(relativePanel)).toBe(false);
});
it("reports each panel's effective range whether or not Auto Run is on", async () => {
setAutoQuery(false);
wrapper = mountWithToolbar();
await flushPromises();
const globalPanel = { id: "P2", config: {} };
wrapper.vm.selectedDate = { valueType: "absolute", startTime: 1, endTime: 2 };
expect(freshOnOpen.isRelativeRange(globalPanel)).toBe(false);
expect(freshOnOpen.isRelativeRange(relativePanel)).toBe(true);
wrapper.vm.selectedDate = { valueType: "relative", relativeTimePeriod: "15m" };
expect(freshOnOpen.isRelativeRange(globalPanel)).toBe(true);
});
it("never applies while the server flag is off", async () => {
setAutoQuery(false);
wrapper = mountWithToolbar();
await flushPromises();
expect(freshOnOpen.appliesTo(relativePanel)).toBe(false);
});
});
});
describe("Basic Component Tests", () => {
@@ -0,0 +1,456 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, shallowMount } from "@vue/test-utils";
import { inject, reactive } from "vue";
import { DASHBOARD_FRESH_ON_OPEN } from "@/composables/dashboard/dashboardFreshOnOpen";
import { AUTO_RUN_STORAGE_KEY, useAutoRunToggle } from "@/composables/dashboard/useAutoRunToggle";
const MINUTE = 60 * 1_000_000;
const route = reactive<{ path: string; params: Record<string, any>; query: Record<string, any> }>({
path: "/dashboards/view",
params: {},
query: {},
});
const routerReplace = vi.fn(async ({ query }: { query: Record<string, any> }) => {
route.query = { ...query };
});
vi.mock("vue-router", () => ({
useRoute: () => route,
useRouter: () => ({
push: vi.fn().mockResolvedValue(undefined),
replace: routerReplace,
back: vi.fn(),
options: { history: { state: { back: null } } },
}),
onBeforeRouteLeave: vi.fn(),
onBeforeRouteUpdate: vi.fn(),
}));
const mockState: Record<string, any> = reactive({
selectedOrganization: { identifier: "test-org" },
printMode: false,
timezone: "UTC",
organizationData: { folders: [{ folderId: "default", name: "Default" }], allDashboardData: {} },
zoConfig: { min_auto_refresh_interval: 5, auto_query_enabled: false },
});
vi.mock("vuex", async (importOriginal) => ({
...((await importOriginal()) as any),
useStore: () => ({ state: mockState, commit: vi.fn(), dispatch: vi.fn() }),
}));
const dashboard = {
dashboardId: "d1",
title: "Dashboard",
variables: { list: [] },
tabs: [
{ tabId: "t1", name: "Tab 1", panels: [{ id: "P2", config: {} }] },
{ tabId: "t2", name: "Tab 2", panels: [] },
],
};
vi.mock("@/utils/commons.ts", async (importOriginal) => ({
...((await importOriginal()) as any),
getDashboard: vi.fn(async () => dashboard),
getFoldersList: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/services/reports", () => ({ default: { list: vi.fn() } }));
vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }));
vi.mock("@/composables/useNotifications", () => ({
default: () => ({
showPositiveNotification: vi.fn(),
showErrorNotification: vi.fn(),
showConfictErrorNotificationWithRefreshBtn: vi.fn(),
}),
}));
vi.mock("@/composables/dashboard/useCancelQuery", () => ({
default: () => ({
traceIdRef: { value: null },
searchRequestTraceIds: vi.fn(),
cancelQuery: vi.fn(),
}),
}));
vi.mock("moment-timezone", () => ({
default: vi.fn(() => ({ tz: vi.fn().mockReturnThis(), format: vi.fn().mockReturnValue("") })),
}));
import ViewDashboard from "@/views/Dashboards/ViewDashboard.vue";
import i18n from "@/locales";
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 pickerCalls = { setSavedDate: vi.fn() };
// Stands in for the real picker: it reports its starting range on mount, and any read of its live value fails.
const PickerStub = {
name: "DateTimePickerDashboard",
props: ["modelValue", "autoApplyDashboard", "disable", "initialTimezone"],
emits: ["update:modelValue", "show", "hide", "timezone-change"],
data: () => ({ zoomed: null as any }),
mounted(this: any) {
this.$emit("update:modelValue", { ...relative("15m"), ...this.modelValue });
},
methods: {
getConsumableDateTime() {
throw new Error("read the live picker instead of the applied range");
},
setCustomDate(this: any, _type: string, range: { start: Date; end: Date }) {
this.zoomed = absolute(range.start.getTime() * 1000, range.end.getTime() * 1000);
},
refresh(this: any) {
if (this.zoomed) this.$emit("update:modelValue", this.zoomed);
},
setSavedDate(value: any) {
pickerCalls.setSavedDate(value);
},
},
template: "<div data-test='picker-stub' />",
};
const RenderDashboardChartsStub = {
name: "RenderDashboardCharts",
props: ["currentTimeObj"],
methods: {
syncAllPanelDateTimePickers: vi.fn(),
commitAllVariables: vi.fn(),
getUrlParams: () => ({}),
},
template: "<div />",
};
describe("ViewDashboard time range gate", () => {
let wrapper: any;
let freshOnOpen: any;
const setAutoRun = (state: "on" | "off" | "unavailable") => {
mockState.zoConfig.auto_query_enabled = state !== "unavailable";
localStorage.setItem(AUTO_RUN_STORAGE_KEY, String(state === "on"));
useAutoRunToggle().syncAutoRun();
};
const mountDashboard = async () => {
wrapper = shallowMount(ViewDashboard, {
global: {
plugins: [i18n],
renderStubDefaultSlot: true,
stubs: {
OPageLayout: {
setup() {
freshOnOpen = inject(DASHBOARD_FRESH_ON_OPEN, null);
},
template: '<div><slot name="actions" /><slot /></div>',
},
ODropdown: { template: '<div><slot name="trigger" /><slot /></div>' },
DateTimePickerDashboard: PickerStub,
RenderDashboardCharts: RenderDashboardChartsStub,
},
},
});
await flushPromises();
routerReplace.mockClear();
};
const picker = () => wrapper.findComponent({ name: "DateTimePickerDashboard" });
const pick = async (value: any) => {
picker().vm.$emit("update:modelValue", value);
await flushPromises();
};
const refreshButton = () => wrapper.findComponent('[data-test="dashboard-refresh-btn"]');
const refreshOptions = () => wrapper.findComponent('[data-test="dashboard-refresh-options-btn"]');
const refreshTooltip = () => refreshButton().findComponent({ name: "OTooltip" }).props("content");
const spanOf = (time: any) => time.end_time.getTime() - time.start_time.getTime();
const globalSpan = () => spanOf(wrapper.vm.currentTimeObjPerPanel.__global);
beforeEach(() => {
route.query = { dashboard: "d1", folder: "default", tab: "t1", period: "15m" };
mockState.printMode = false;
pickerCalls.setSavedDate.mockClear();
setAutoRun("off");
});
afterEach(() => {
wrapper?.unmount();
localStorage.removeItem(AUTO_RUN_STORAGE_KEY);
});
it("shows the main picker without an Apply button", async () => {
await mountDashboard();
expect(picker().props("autoApplyDashboard")).toBe(true);
});
describe.each(["off", "unavailable"] as const)("Auto Run %s", (state) => {
beforeEach(() => setAutoRun(state));
it("keeps a picked range waiting: panels, URL and Share link stay on the applied range", async () => {
await mountDashboard();
const shareBefore = wrapper.vm.dashboardShareURL;
await pick(relative("1h"));
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("15m");
expect(picker().props("modelValue").relativeTimePeriod).toBe("1h");
expect(globalSpan()).toBe(15 * MINUTE);
expect(routerReplace).not.toHaveBeenCalled();
expect(route.query.period).toBe("15m");
expect(wrapper.vm.dashboardShareURL).toBe(shareBefore);
});
it("turns Refresh yellow with the time-range tooltip while a range waits", async () => {
await mountDashboard();
expect(refreshButton().props("variant")).toBe("outline");
await pick(relative("1h"));
expect(refreshButton().props("variant")).toBe("warning");
expect(refreshOptions().props("variant")).toBe("warning");
expect(refreshTooltip()).toBe("Refresh to apply the time range");
});
it("applies the waiting range on Refresh and only then updates the URL", async () => {
await mountDashboard();
await pick(relative("1h"));
refreshButton().vm.$emit("click");
await flushPromises();
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h");
expect(globalSpan()).toBe(60 * MINUTE);
expect(route.query.period).toBe("1h");
expect(refreshButton().props("variant")).toBe("outline");
});
it("applies the waiting range on an auto-refresh tick", async () => {
await mountDashboard();
await pick(relative("1h"));
wrapper.findComponent({ name: "AutoRefreshInterval" }).vm.$emit("trigger");
await flushPromises();
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h");
});
it("clears the signal without a reload when the applied range is picked again", async () => {
await mountDashboard();
const appliedBefore = wrapper.vm.currentTimeObjPerPanel.__global;
await pick(relative("1h"));
await pick(relative("15m"));
expect(refreshButton().props("variant")).toBe("outline");
expect(wrapper.vm.currentTimeObjPerPanel.__global).toBe(appliedBefore);
expect(routerReplace).not.toHaveBeenCalled();
});
it("never applies or signals a broken range", async () => {
await mountDashboard();
await pick(absolute(NaN, 200));
expect(refreshButton().props("variant")).toBe("outline");
await pick(absolute(300 * MINUTE, 200 * MINUTE));
expect(refreshButton().props("variant")).toBe("outline");
refreshButton().vm.$emit("click");
await flushPromises();
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("15m");
});
it("puts the picker back on the applied range when it closes on a broken range", async () => {
await mountDashboard();
picker().vm.$emit("show");
await pick(absolute(300 * MINUTE, 200 * MINUTE));
expect(pickerCalls.setSavedDate).not.toHaveBeenCalled();
picker().vm.$emit("hide");
await flushPromises();
expect(pickerCalls.setSavedDate).toHaveBeenCalledWith({
type: "relative",
relativeTimePeriod: "15m",
});
expect(picker().props("modelValue").relativeTimePeriod).toBe("15m");
expect(refreshButton().props("variant")).toBe("outline");
expect(routerReplace).not.toHaveBeenCalled();
});
});
it("keeps the yellow Refresh and applies a waiting range in layout edit mode", async () => {
await mountDashboard();
wrapper.vm.enterLayoutEdit();
await flushPromises();
expect(wrapper.vm.isEditingLayout).toBe(true);
await pick(relative("1h"));
expect(refreshButton().props("variant")).toBe("warning");
refreshButton().vm.$emit("click");
await flushPromises();
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h");
expect(refreshButton().props("variant")).toBe("outline");
expect(wrapper.vm.isEditingLayout).toBe(true);
});
it("names both the time range and variables when both wait", async () => {
await mountDashboard();
await wrapper.vm.onVariablesManagerReady({ hasUncommittedChanges: true });
expect(refreshTooltip()).toBe("Refresh to apply latest variable changes");
await pick(relative("1h"));
expect(refreshButton().props("variant")).toBe("warning");
expect(refreshTooltip()).toBe("Refresh to apply the time range and variable changes");
});
describe("everything that loads data reads the applied range", () => {
it("a tab switch keeps every panel on the applied range", async () => {
await mountDashboard();
await pick(relative("1h"));
route.query = { ...route.query, tab: "t2" };
await flushPromises();
expect(globalSpan()).toBe(15 * MINUTE);
});
it("a panel's own refresh reloads it on the applied range", async () => {
await mountDashboard();
await pick(relative("1h"));
wrapper.vm.refreshPanelRequest("P2", false);
expect(spanOf(wrapper.vm.currentTimeObjPerPanel.P2)).toBe(15 * MINUTE);
});
it("variables get the applied range when the picker closes", async () => {
await mountDashboard();
picker().vm.$emit("show");
await pick(relative("1h"));
picker().vm.$emit("hide");
await flushPromises();
expect(spanOf(wrapper.vm.currentTimeObjPerPanel.__variables)).toBe(15 * MINUTE);
});
it("the fresh-on-open gate follows the applied range type", async () => {
route.query = { dashboard: "d1", folder: "default", tab: "t1", from: "1", to: "2" };
await mountDashboard();
await pick(relative("1h"));
expect(freshOnOpen.isRelativeRange({ id: "P2", config: {} })).toBe(false);
});
});
it("applies a chart zoom at once even while Auto Run is off", async () => {
await mountDashboard();
wrapper.vm.onDataZoom({ start: 1_700_000_000_000, end: 1_700_000_600_000 });
await flushPromises();
expect(wrapper.vm.selectedDate.valueType).toBe("absolute");
expect(globalSpan()).toBe(10 * MINUTE);
expect(refreshButton().props("variant")).toBe("outline");
});
describe("Auto Run on", () => {
beforeEach(() => setAutoRun("on"));
it("applies a preset at once and updates the URL", async () => {
await mountDashboard();
await pick(relative("1h"));
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h");
expect(globalSpan()).toBe(60 * MINUTE);
expect(route.query.period).toBe("1h");
expect(refreshButton().props("variant")).toBe("outline");
expect(refreshTooltip()).toBe("Runs queries on open and on filter/time changes");
});
it("holds an absolute edit while the picker is open and applies it when it closes", async () => {
await mountDashboard();
const end = 1_700_000_000_000_000;
picker().vm.$emit("show");
await pick(absolute(end - 30 * MINUTE, end));
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("15m");
expect(refreshButton().props("variant")).toBe("outline");
picker().vm.$emit("hide");
await flushPromises();
expect(wrapper.vm.selectedDate).toEqual(absolute(end - 30 * MINUTE, end));
expect(route.query.from).toBe(end - 30 * MINUTE);
expect(globalSpan()).toBe(30 * MINUTE);
});
it("applies a range that waited while Auto Run was off once it is turned on", async () => {
setAutoRun("off");
await mountDashboard();
await pick(relative("1h"));
wrapper.vm.toggleAutoRun();
await flushPromises();
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h");
expect(route.query.period).toBe("1h");
});
});
it("shows a range another dashboard's URL applied in the picker instead of leaving it waiting", async () => {
await mountDashboard();
route.query = { ...route.query, period: "6h" };
await wrapper.vm.loadDashboard();
await flushPromises();
expect(pickerCalls.setSavedDate).toHaveBeenCalledWith({
type: "relative",
relativeTimePeriod: "6h",
});
expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("6h");
expect(refreshButton().props("variant")).toBe("outline");
});
it("leaves the picker alone when a reload keeps the same range", async () => {
await mountDashboard();
await pick(relative("1h"));
await wrapper.vm.loadDashboard();
await flushPromises();
expect(pickerCalls.setSavedDate).not.toHaveBeenCalled();
expect(refreshButton().props("variant")).toBe("warning");
});
});