mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
co-authored by
github-actions[bot]
parent
975a81b97b
commit
389de2a1b6
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user