From 389de2a1b60366f1a7ce4197df806951a7fa8e63 Mon Sep 17 00:00:00 2001 From: ktx-mihir Date: Thu, 15 Oct 2026 08:53:39 +0000 Subject: [PATCH] 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> --- scripts/translations/.translation_state.json | 4 + .../dashboardPages/dashboard-panel-time.js | 62 +- .../pages/dashboardPages/dashboard-refresh.js | 7 +- .../pages/dashboardPages/dashboard-time.js | 27 +- .../dashboard-variables-auto-run.spec.js | 2 +- .../DateTimePickerDashboard.spec.ts | 110 ++- .../components/DateTimePickerDashboard.vue | 8 +- .../dashboards/PanelContainer.spec.ts | 68 ++ .../components/dashboards/PanelContainer.vue | 19 +- .../dashboards/PanelSchemaRenderer.spec.ts | 130 +++ .../dashboards/PanelSchemaRenderer.vue | 17 +- .../dashboards/viewPanel/ViewPanel.spec.ts | 193 ++++- .../dashboards/viewPanel/ViewPanel.vue | 93 ++- .../dashboard/dashboardFreshOnOpen.ts | 50 ++ .../dashboard/useDashboardTimeGate.spec.ts | 529 ++++++++++++ .../dashboard/useDashboardTimeGate.ts | 135 +++ .../usePanelDataLoader.freshOnOpen.spec.ts | 777 ++++++++++++++++++ .../dashboard/usePanelDataLoader.ts | 69 +- .../usePanelRefreshAfterRestore.spec.ts | 166 ++++ .../dashboard/usePanelRefreshAfterRestore.ts | 147 ++++ web/src/locales/languages/ar-SA.json | 8 +- web/src/locales/languages/de-DE.json | 8 +- web/src/locales/languages/en-US.json | 4 + web/src/locales/languages/es-ES.json | 8 +- web/src/locales/languages/fr-FR.json | 8 +- web/src/locales/languages/it-IT.json | 8 +- web/src/locales/languages/ja-JP.json | 8 +- web/src/locales/languages/ko-KR.json | 8 +- web/src/locales/languages/nl-NL.json | 8 +- web/src/locales/languages/pl-PL.json | 8 +- web/src/locales/languages/pt-PT.json | 8 +- web/src/locales/languages/ru-RU.json | 8 +- web/src/locales/languages/tr-TR.json | 8 +- web/src/locales/languages/vi-VN.json | 8 +- web/src/locales/languages/zh-CN.json | 8 +- web/src/locales/languages/zh-TW.json | 8 +- .../panelTimeUtils.effectiveRelative.spec.ts | 80 ++ .../panelTimeUtils.pickerValues.spec.ts | 153 ++++ web/src/utils/dashboard/panelTimeUtils.ts | 87 ++ .../Dashboards/RenderDashboardCharts.spec.ts | 217 +++++ .../Dashboards/RenderDashboardCharts.vue | 157 ++-- web/src/views/Dashboards/ViewDashboard.vue | 194 +++-- .../views/Dashboards/viewDashboard.spec.ts | 163 ++++ .../viewDashboard.timeRange.spec.ts | 456 ++++++++++ 44 files changed, 3914 insertions(+), 330 deletions(-) create mode 100644 web/src/composables/dashboard/dashboardFreshOnOpen.ts create mode 100644 web/src/composables/dashboard/useDashboardTimeGate.spec.ts create mode 100644 web/src/composables/dashboard/useDashboardTimeGate.ts create mode 100644 web/src/composables/dashboard/usePanelDataLoader.freshOnOpen.spec.ts create mode 100644 web/src/composables/dashboard/usePanelRefreshAfterRestore.spec.ts create mode 100644 web/src/composables/dashboard/usePanelRefreshAfterRestore.ts create mode 100644 web/src/utils/dashboard/panelTimeUtils.effectiveRelative.spec.ts create mode 100644 web/src/utils/dashboard/panelTimeUtils.pickerValues.spec.ts create mode 100644 web/src/views/Dashboards/viewDashboard.timeRange.spec.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index f690572f5d..c829ca0e94 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -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", diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js b/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js index 6ef1a2628d..33f2a67e28 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js @@ -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; diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js b/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js index 10b2b3938e..a3c263556c 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js @@ -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(() => {}); } } diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-time.js b/tests/ui-testing/pages/dashboardPages/dashboard-time.js index 7382ecc684..6d208ab27a 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-time.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-time.js @@ -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 { diff --git a/tests/ui-testing/playwright-tests/Dashboards/dashboard-variables-auto-run.spec.js b/tests/ui-testing/playwright-tests/Dashboards/dashboard-variables-auto-run.spec.js index 8c0c8174d6..cd908be556 100644 --- a/tests/ui-testing/playwright-tests/Dashboards/dashboard-variables-auto-run.spec.js +++ b/tests/ui-testing/playwright-tests/Dashboards/dashboard-variables-auto-run.spec.js @@ -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(); diff --git a/web/src/components/DateTimePickerDashboard.spec.ts b/web/src/components/DateTimePickerDashboard.spec.ts index 0105a99ec7..7129506928 100644 --- a/web/src/components/DateTimePickerDashboard.spec.ts +++ b/web/src/components/DateTimePickerDashboard.spec.ts @@ -14,10 +14,14 @@ // along with this program. If not, see . 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(null); + const gate = useDashboardTimeGate({ + isAutoRunOn: () => false, + showInPicker: (value) => pickerRef.value?.setSavedDate(toPickerSavedDate(value, "UTC")), + }); + return { gate, pickerRef }; + }, + template: ``, + }); + + const openPicker = async (host: any) => { + host.findComponent(DateTime).vm.menuOpen = true; + await flushPromises(); + }; + const inMenu = (dataTest: string) => + document.body.querySelector(`[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(); + }); }); }); diff --git a/web/src/components/DateTimePickerDashboard.vue b/web/src/components/DateTimePickerDashboard.vue index dce66882ce..663a0411f6 100644 --- a/web/src/components/DateTimePickerDashboard.vue +++ b/web/src/components/DateTimePickerDashboard.vue @@ -24,6 +24,7 @@ along with this program. If not, see . }" :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, }, }, - 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, }; }, }); diff --git a/web/src/components/dashboards/PanelContainer.spec.ts b/web/src/components/dashboards/PanelContainer.spec.ts index e841674595..b443a259e7 100644 --- a/web/src/components/dashboards/PanelContainer.spec.ts +++ b/web/src/components/dashboards/PanelContainer.spec.ts @@ -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", () => { diff --git a/web/src/components/dashboards/PanelContainer.vue b/web/src/components/dashboards/PanelContainer.vue index f0c1658f9b..c7358832b9 100644 --- a/web/src/components/dashboards/PanelContainer.vue +++ b/web/src/components/dashboards/PanelContainer.vue @@ -234,7 +234,7 @@ along with this program. If not, see . /> . :disabled="isPanelLoading" icon-left="refresh" > - + { + 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, diff --git a/web/src/components/dashboards/PanelSchemaRenderer.spec.ts b/web/src/components/dashboards/PanelSchemaRenderer.spec.ts index 4d81ab20ad..42dca8c2ac 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.spec.ts +++ b/web/src/components/dashboards/PanelSchemaRenderer.spec.ts @@ -1258,6 +1258,136 @@ describe("PanelSchemaRenderer", () => { }); }); + describe("Cache restore and the first load", () => { + describe("before the first load settles", () => { + const loaderWith = (overrides: Record) => ({ + 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([]); + 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(); diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index ab588af634..5195e3e1c1 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -21,7 +21,10 @@ along with this program. If not, see . @mouseenter="showPopupsAndOverlays" >
-
+
. { + // Converting the empty pre-load buffer would publish an empty result to the header and curated tiles. + if (isAwaitingFirstLoad.value) return; // Preserve the previously rendered chart during a reload. While loading, // if the new data buffer has no rows yet but a chart is already rendered, // skip conversion so non-streaming callers (the panelSchema deep watcher, @@ -2176,6 +2190,7 @@ export default defineComponent({ errorDetail, panelData, noData, + isAwaitingFirstLoad, chartRendererData, metadata, tableRendererRef, diff --git a/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts b/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts index 3fd0c1e608..d5ad5a0c7c 100644 --- a/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts +++ b/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts @@ -164,7 +164,8 @@ vi.mock("@/composables/dashboard/useCancelQuery", () => ({ })); // Mock date utilities -vi.mock("@/utils/date", () => ({ +vi.mock("@/utils/date", async (importOriginal) => ({ + ...((await importOriginal()) as any), parseDuration: vi.fn((duration: string) => { if (duration === "30s") return 30; if (duration === "1m") return 60; @@ -184,6 +185,8 @@ vi.mock("lodash-es", () => ({ isEqual: vi.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)), })); +const pickerSetSavedDate = vi.fn(); + describe("ViewPanel", () => { let wrapper: VueWrapper; let mockGetDashboard: any; @@ -202,7 +205,7 @@ describe("ViewPanel", () => { searchType: "logs", }; - const createWrapper = (props = {}) => { + const createWrapper = (props = {}, extraStubs = {}) => { return mount(ViewPanel, { props: { ...defaultProps, @@ -215,9 +218,13 @@ describe("ViewPanel", () => { }, stubs: { DateTimePickerDashboard: { + name: "DateTimePickerDashboard", template: '
', + props: ["modelValue", "autoApplyDashboard", "disable"], + emits: ["update:modelValue", "show", "hide", "timezone-change"], methods: { refresh: vi.fn(), + setSavedDate: pickerSetSavedDate, getConsumableDateTime: () => ({ startTime: "2023-01-01T00:00:00Z", endTime: "2023-01-02T00:00:00Z", @@ -349,6 +356,7 @@ describe("ViewPanel", () => { props: ["panelData", "open"], emits: ["update:open"], }, + ...extraStubs, }, }, }); @@ -1034,21 +1042,147 @@ describe("ViewPanel", () => { it("should refresh data when not disabled", () => { wrapper = createWrapper(); wrapper.vm.disable = false; + const appliedBefore = wrapper.vm.selectedDate; - const refreshSpy = vi.spyOn(wrapper.vm.dateTimePickerRef, "refresh"); wrapper.vm.refreshData(); - expect(refreshSpy).toHaveBeenCalled(); + expect(wrapper.vm.selectedDate).not.toBe(appliedBefore); + expect(wrapper.vm.selectedDate).toEqual(appliedBefore); }); it("should not refresh data when disabled", () => { wrapper = createWrapper(); wrapper.vm.disable = true; + const appliedBefore = wrapper.vm.selectedDate; - const refreshSpy = vi.spyOn(wrapper.vm.dateTimePickerRef, "refresh"); wrapper.vm.refreshData(); - expect(refreshSpy).not.toHaveBeenCalled(); + expect(wrapper.vm.selectedDate).toBe(appliedBefore); + }); + }); + + describe("time range without Apply", () => { + const MINUTE = 60 * 1_000_000; + const relative = (period: string) => ({ + valueType: "relative", + relativeTimePeriod: period, + startTime: 1, + endTime: 2, + }); + const setAutoRun = (on: boolean) => { + mockStore.state.zoConfig = { ...mockStore.state.zoConfig, auto_query_enabled: on }; + localStorage.setItem("oo_toggle_auto_run", String(on)); + }; + const picker = () => wrapper.findComponent({ name: "DateTimePickerDashboard" }); + const pick = async (value: any) => { + picker().vm.$emit("update:modelValue", value); + await flushPromises(); + }; + const refreshTooltip = () => + wrapper + .findComponent('[data-test="dashboard-viewpanel-refresh-data-btn"]') + .findComponent({ name: "OTooltip" }) + .props("content"); + const chartSpan = () => { + const time = wrapper.vm.dashboardPanelData.meta.dateTime; + return time.end_time.getTime() - time.start_time.getTime(); + }; + const mountOnRange = async () => { + wrapper = createWrapper({ selectedDateForViewPanel: relative("15m") }); + await flushPromises(); + await pick(relative("15m")); + }; + + afterEach(() => { + localStorage.removeItem("oo_toggle_auto_run"); + mockStore.state.zoConfig = { ...mockStore.state.zoConfig, auto_query_enabled: false }; + }); + + it("uses the picker without an Apply button", async () => { + await mountOnRange(); + + expect(picker().props("autoApplyDashboard")).toBe(true); + expect(chartSpan()).toBe(15 * MINUTE); + }); + + it("keeps a picked range waiting with Auto Run off and turns Refresh yellow", async () => { + setAutoRun(false); + await mountOnRange(); + + await pick(relative("1h")); + + expect(picker().props("modelValue").relativeTimePeriod).toBe("1h"); + expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("15m"); + expect(chartSpan()).toBe(15 * MINUTE); + expect(wrapper.vm.showApplyHint).toBe(true); + expect(refreshTooltip()).toBe("dashboard.refreshToApplyTimeRange"); + }); + + it("applies the waiting range on Refresh", async () => { + setAutoRun(false); + await mountOnRange(); + await pick(relative("1h")); + + wrapper.vm.disable = false; + wrapper.vm.refreshData(); + await flushPromises(); + + expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h"); + expect(chartSpan()).toBe(60 * MINUTE); + expect(wrapper.vm.showApplyHint).toBe(false); + }); + + it("names both when variables are waiting too", async () => { + setAutoRun(false); + await mountOnRange(); + wrapper.vm.isVariablesChanged = true; + + await pick(relative("1h")); + + expect(refreshTooltip()).toBe("dashboard.refreshToApplyTimeRangeAndVariables"); + }); + + it("loads variables on the applied range when the picker closes", async () => { + setAutoRun(false); + await mountOnRange(); + picker().vm.$emit("show"); + await pick(relative("1h")); + + picker().vm.$emit("hide"); + await flushPromises(); + + const time = wrapper.vm.dateTimeForVariables; + expect(time.end_time.getTime() - time.start_time.getTime()).toBe(15 * MINUTE); + }); + + it("puts the picker back on the applied range when it closes on a broken range", async () => { + setAutoRun(false); + await mountOnRange(); + pickerSetSavedDate.mockClear(); + picker().vm.$emit("show"); + await pick({ valueType: "absolute", startTime: 300 * MINUTE, endTime: 200 * MINUTE }); + + picker().vm.$emit("hide"); + await flushPromises(); + + expect(pickerSetSavedDate).toHaveBeenCalledWith({ + type: "relative", + relativeTimePeriod: "15m", + }); + expect(picker().props("modelValue").relativeTimePeriod).toBe("15m"); + expect(wrapper.vm.showApplyHint).toBe(false); + expect(chartSpan()).toBe(15 * MINUTE); + }); + + it("applies a preset at once with Auto Run on", async () => { + setAutoRun(true); + await mountOnRange(); + + await pick(relative("1h")); + + expect(wrapper.vm.selectedDate.relativeTimePeriod).toBe("1h"); + expect(chartSpan()).toBe(60 * MINUTE); + expect(wrapper.vm.showApplyHint).toBe(false); }); }); @@ -1114,6 +1248,53 @@ describe("ViewPanel", () => { expect(wrapper.vm.isAutoRunOn).toBe(true); expect(wrapper.vm.showApplyHint).toBe(false); }); + + it("uses the dashboard Auto Run copy for the Refresh tooltip while Auto Run is on", async () => { + setAutoQuery(true); + wrapper = createWrapper(); + await flushPromises(); + + const tooltip = wrapper + .findComponent('[data-test="dashboard-viewpanel-refresh-data-btn"]') + .findComponent({ name: "OTooltip" }) + .props("content"); + expect(tooltip).toBe("dashboard.viewDashboard.autoRunDescription"); + }); + + it("describes Auto Run in its menu item like the dashboard toolbar, in both states", async () => { + setAutoQuery(true); + wrapper = createWrapper( + {}, + { + ODropdown: { template: '' }, + ODropdownItem: { template: '' }, + }, + ); + await flushPromises(); + const toggle = () => wrapper.find('[data-test="dashboard-viewpanel-auto-run-toggle-btn"]'); + const lines = () => + toggle() + .findAll("div") + .map((line: any) => line.text()); + + expect(lines()).toEqual([ + "search.turnOffLiveMode", + "dashboard.viewDashboard.autoRunDescription", + "dashboard.viewDashboard.autoRunScope", + ]); + expect(toggle().find('[data-test="dashboard-viewpanel-auto-run-scope"]').classes()).toEqual( + expect.arrayContaining(["text-text-secondary", "text-xs"]), + ); + + wrapper.vm.toggleAutoRun(); + await flushPromises(); + + expect(lines()).toEqual([ + "search.turnOnLiveMode", + "dashboard.viewDashboard.autoRunDescription", + "dashboard.viewDashboard.autoRunScope", + ]); + }); }); describe("Component Lifecycle", () => { diff --git a/web/src/components/dashboards/viewPanel/ViewPanel.vue b/web/src/components/dashboards/viewPanel/ViewPanel.vue index 8e4b094f1f..1ec336a112 100644 --- a/web/src/components/dashboards/viewPanel/ViewPanel.vue +++ b/web/src/components/dashboards/viewPanel/ViewPanel.vue @@ -43,12 +43,16 @@ /> - +