diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index ae30441742..6705d40c47 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -16354,6 +16354,11 @@ "addWeightShort": "ce19e6f74c7d6d0c", "addZAxisShort": "80cb9abde90c2312", "apply": "31e392d1c0378bec", + "applyTooltipIncomplete": "664c159cf7ad8161", + "applyTooltipPending": "19108103aff03e6b", + "applyTooltipRefresh": "53b2a7418df0e226", + "applyTooltipSlow": "f96f3e6cc58ef377", + "applyTooltipTyped": "f733b9d0cb483951", "breakdown": "3ffe201a20342a4c", "breakdownTooltipDefault": "32dfc6cecb86c976", "breakdownTooltipHBar": "5ca95abd5acace70", diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js b/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js index 5013fa9975..6ef1a2628d 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-panel-time.js @@ -1,4 +1,5 @@ import { expect } from "@playwright/test"; +import { applyPickedDateTime } from "./dashboard-time.js"; /** * Page Object for Panel Time Range operations @@ -529,9 +530,7 @@ export default class DashboardPanelTime { await timeOptionBtn.waitFor({ state: "visible", timeout: 5000 }); await timeOptionBtn.click(); - // Click apply button within the dialog - const applyBtn = this.dateTimeMenu.locator('[data-test="date-time-apply-btn"]'); - await applyBtn.click(); + await applyPickedDateTime(this.page); // Wait for dialog to close and network to settle await this.dateTimeMenu.waitFor({ state: "hidden", timeout: 5000 }); @@ -655,9 +654,7 @@ export default class DashboardPanelTime { * Click the Apply button in an open date time picker */ async clickDateTimeApply() { - const applyBtn = this.dateTimeMenu.locator('[data-test="date-time-apply-btn"]'); - await applyBtn.scrollIntoViewIfNeeded(); - await applyBtn.click(); + await applyPickedDateTime(this.page); await this.dateTimeMenu.waitFor({ state: "hidden", timeout: 5000 }); await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); } diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js b/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js index c1eb685ac6..10b2b3938e 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-refresh.js @@ -1,3 +1,5 @@ +import { applyPickedDateTime } from "./dashboard-time.js"; + //Dahboard time refresh // Methods: Set relative time selection, Set absolute time selection, Auto refresh interval, Refresh dashboard manual export default class DashboardTimeRefresh { @@ -49,7 +51,7 @@ export default class DashboardTimeRefresh { await relBtn.waitFor({ state: "attached" }); // Use JS click — the dropdown can extend outside the viewport in the new layout await relBtn.evaluate((el) => el.click()); - await this.applyBtn.evaluate((el) => el.click()); + await applyPickedDateTime(this.page); } // Set absolute time selection with dynamic start and end days @@ -131,9 +133,7 @@ export default class DashboardTimeRefresh { await endCell.waitFor({ state: "attached" }); await endCell.evaluate((el) => el.click()); - // Click the apply button to confirm the selection — JS click matches - // setRelative() and avoids viewport-clipped action errors. - await this.applyBtn.evaluate((el) => el.click()); + 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. diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-time.js b/tests/ui-testing/pages/dashboardPages/dashboard-time.js index d0f1a611d3..7382ecc684 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-time.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-time.js @@ -7,11 +7,24 @@ 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. +export async function applyPickedDateTime(page) { + const pickerApply = page.locator('[data-test="date-time-apply-btn"]'); + if (await pickerApply.count()) { + await pickerApply.first().evaluate((el) => el.click()); + return; + } + 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())); +} + export default class DateTimeHelper { constructor(page) { this.page = page; this.timePickerBtn = page.locator('[data-test="date-time-btn"]'); - this.applyTimeBtn = page.locator('[data-test="date-time-apply-btn"]'); } // set relative time range async setRelativeTimeRange(rangeCode) { @@ -30,6 +43,6 @@ export default class DateTimeHelper { // Use JS click — the dropdown can extend outside the viewport in the new layout await relBtn.evaluate((el) => el.click()); - await this.applyTimeBtn.evaluate((el) => el.click()); + await applyPickedDateTime(this.page); } } diff --git a/tests/ui-testing/playwright-tests/Dashboards/dashboard-exemplars.spec.js b/tests/ui-testing/playwright-tests/Dashboards/dashboard-exemplars.spec.js index 01b5514aa7..700a2d13bf 100644 --- a/tests/ui-testing/playwright-tests/Dashboards/dashboard-exemplars.spec.js +++ b/tests/ui-testing/playwright-tests/Dashboards/dashboard-exemplars.spec.js @@ -324,11 +324,11 @@ test.describe('Dashboard exemplars', () => { await openDashboard(page, dash); await page.locator('[data-test="dashboard-edit-panel-Edit-dropdown"]').click(); await page.locator('[data-test="dashboard-edit-panel"]').click(); - // The editor normalises an API-built panel's query fields on open, so the preview starts out of date. - const outdated = page.getByText('Your chart is not up to date'); - await expect(outdated).toBeVisible({ timeout: 15_000 }); - await page.locator('[data-test="dashboard-apply"]').click(); - await expect(outdated).toHaveCount(0, { timeout: 30_000 }); + // Apply re-runs the panel with whatever the editor normalised on open. + const apply = page.locator('[data-test="dashboard-apply"]'); + await expect(apply).toBeEnabled({ timeout: 15_000 }); + await apply.click(); + await expect(apply).toBeEnabled({ timeout: 30_000 }); expect(await page.locator(sel.points).count()).toBe(0); await page.locator('[data-test="panel-sidebar-header-collapsed"]').click(); await page.locator('[data-test="dashboard-config-panel-search"] input').fill('exemplars'); diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.vue b/web/src/components/dashboards/PanelEditor/PanelEditor.vue index 66dca9a7ba..6213abda05 100644 --- a/web/src/components/dashboards/PanelEditor/PanelEditor.vue +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.vue @@ -780,6 +780,7 @@ const props = withDefaults(defineProps(), { variablesData: undefined, dashboardData: undefined, editMode: false, + holdQueries: false, }); const emit = defineEmits(); @@ -844,12 +845,14 @@ const { // Computed isOutDated, + sparklinePendingApply, isLoading, currentPanelData, // Actions initChartData, runQuery, + reportValidationErrors, handleChartApiError, handleLastTriggeredAtUpdate, handleLimitNumberOfSeriesWarningMessage, @@ -880,6 +883,7 @@ const { dateTimePickerRef: undefined, // Managed by parent selectedDate: undefined, // Managed by parent validatePanel, + holdQueries: () => props.holdQueries, }); // The desktop 20% field-list splitter is unusable on a phone, so there it opens at ~half width. @@ -1327,6 +1331,7 @@ defineExpose({ // Actions initChartData, runQuery, + reportValidationErrors, resetErrors, collapseFieldList, updateDateTime, @@ -1342,6 +1347,7 @@ defineExpose({ // Panel data dashboardPanelData, isOutDated, + sparklinePendingApply, // Loading state isLoading, diff --git a/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts index 73aa224ece..a1d7a41d98 100644 --- a/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts +++ b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import i18nInstance from "@/locales"; import { ref, reactive, nextTick } from "vue"; import { usePanelEditor } from "./usePanelEditor"; +import { + LIVE_PREVIEW_DEBOUNCE_MS, + usePanelLivePreview, +} from "@/composables/dashboard/usePanelLivePreview"; // Mock vuex store vi.mock("vuex", () => ({ @@ -408,6 +412,21 @@ describe("usePanelEditor", () => { ]); }); + it("does not list a removed axis field as a function field when results arrive late", async () => { + dashboardPanelData.data.queries[0].fields.x = [{ alias: "x_axis_1", isDerived: false }]; + dashboardPanelData.data.queries[0].fields.y = [{ alias: "y_axis_1", isDerived: false }]; + const { updateVrlFunctionFieldList, initChartData } = usePanelEditor(options); + initChartData(); + await nextTick(); + + dashboardPanelData.data.queries[0].fields.y = []; + updateVrlFunctionFieldList(["x_axis_1", "y_axis_1", "vrlField"]); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "vrlField", type: "Utf8" }, + ]); + }); + it("should filter out customQueryFields", () => { // customQueryFields are read from the per-query cache (meta.queryFields) dashboardPanelData.meta.queryFields[0] = { @@ -700,6 +719,121 @@ describe("usePanelEditor", () => { }); }); + describe("reportValidationErrors", () => { + it("lists the errors without touching the chart", () => { + const { reportValidationErrors, errorData, chartData } = usePanelEditor(options); + reportValidationErrors(["Filter on level needs a value"]); + expect(errorData.errors).toEqual(["Filter on level needs a value"]); + expect(chartData.value).toBeUndefined(); + }); + }); + + describe("held queries (typed text waiting for Apply)", () => { + const typeIntoQuery = () => { + dashboardPanelData.data.queries[0].customQuery = true; + dashboardPanelData.data.queries[0].query = "SELECT * FROM logs WHERE"; + }; + + it("initChartData keeps the last-run queries when asked", async () => { + const { chartData, initChartData } = usePanelEditor(options); + initChartData(); + await nextTick(); + typeIntoQuery(); + dashboardPanelData.data.description = "edited"; + + initChartData(undefined, { keepQueries: true }); + await nextTick(); + + expect(chartData.value?.queries[0].query).toBe("SELECT * FROM logs"); + expect(chartData.value?.description).toBe("edited"); + }); + + it("a chart type change keeps the last-run queries while holdQueries is on", async () => { + const { chartData, initChartData } = usePanelEditor({ ...options, holdQueries: () => true }); + initChartData(); + await nextTick(); + typeIntoQuery(); + + dashboardPanelData.data.type = "bar"; + await nextTick(); + await nextTick(); + + expect(chartData.value?.type).toBe("bar"); + expect(chartData.value?.queries[0].query).toBe("SELECT * FROM logs"); + }); + + it("a chart type change takes the current queries when nothing is held", async () => { + const { chartData, initChartData } = usePanelEditor(options); + initChartData(); + await nextTick(); + dashboardPanelData.data.queries[0].query = "SELECT count(*) FROM logs"; + + dashboardPanelData.data.type = "bar"; + await nextTick(); + await nextTick(); + + expect(chartData.value?.queries[0].query).toBe("SELECT count(*) FROM logs"); + }); + }); + + describe("with the dashboard live preview", () => { + const setupLive = () => { + const editor = usePanelEditor({ ...options, holdQueries: () => live.isTypedPending.value }); + const live = usePanelLivePreview({ + panel: () => dashboardPanelData.data, + applied: () => editor.chartData.value, + liveVariables: () => [], + committedVariables: () => [], + isBuilderValid: () => true, + isLoading: ref(false), + run: () => editor.initChartData(), + isOverlayOpen: () => false, + }); + return { editor, live }; + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("pushes a builder edit to the chart after the debounce", async () => { + const { editor, live } = setupLive(); + editor.initChartData(); + await nextTick(); + live.arm(); + + dashboardPanelData.data.queries[0].query = "SELECT count(*) FROM logs"; + await nextTick(); + expect(editor.chartData.value?.queries[0].query).toBe("SELECT * FROM logs"); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(editor.chartData.value?.queries[0].query).toBe("SELECT count(*) FROM logs"); + }); + + it("never sends another tab's typed text with a builder edit", async () => { + dashboardPanelData.data.queries.push({ + ...JSON.parse(JSON.stringify(dashboardPanelData.data.queries[0])), + customQuery: true, + query: "SELECT 1", + }); + const { editor, live } = setupLive(); + editor.initChartData(); + await nextTick(); + live.arm(); + + dashboardPanelData.data.queries[1].query = "SELECT * FROM logs WHERE"; + dashboardPanelData.data.queries[0].query = "SELECT count(*) FROM logs"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + + expect(editor.chartData.value?.queries[1].query).toBe("SELECT 1"); + expect(live.isPending.value).toBe(true); + }); + }); + describe("runQuery", () => { it("should copy dashboardPanelData.data to chartData", () => { const { runQuery, chartData } = usePanelEditor(options); @@ -735,6 +869,17 @@ describe("usePanelEditor", () => { expect(mockUpdateCommittedVariables).toHaveBeenCalled(); }); + + it("reports a turned-on sparkline as pending until the panel runs", () => { + const { runQuery, sparklinePendingApply } = usePanelEditor(options); + expect(sparklinePendingApply.value).toBe(false); + + dashboardPanelData.data.config.sparkline = { enabled: true }; + expect(sparklinePendingApply.value).toBe(true); + + runQuery(); + expect(sparklinePendingApply.value).toBe(false); + }); }); describe("handleChartApiError", () => { diff --git a/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts index d89f88c30f..fbe45d93d5 100644 --- a/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts +++ b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts @@ -60,6 +60,13 @@ export interface UsePanelEditorOptions { selectedDate?: Ref; /** Validate panel fields (from useDashboardPanelData) */ validatePanel?: (errors: string[], isFieldsValidationRequired?: boolean) => void; + /** While true, render-only syncs keep the queries the chart last ran (typed text waits for Apply). */ + holdQueries?: () => boolean; +} + +export interface InitChartDataOptions { + /** Keep the queries the chart last ran instead of taking the panel's current ones. */ + keepQueries?: boolean; } /** @@ -80,6 +87,7 @@ export function usePanelEditor(options: UsePanelEditorOptions) { dateTimePickerRef, selectedDate, validatePanel, + holdQueries, } = options; const store = useStore(); @@ -363,6 +371,14 @@ export function usePanelEditor(options: UsePanelEditorOptions) { } }; + /** Shows validation errors the way runQuery does, without running anything. */ + const reportValidationErrors = (errors: string[]): void => { + errorData.errors.splice(0, errorData.errors.length, ...errors); + if (errors.length) { + showErrorNotification(t("toastMessages.composables.thereAreSomeErrorsPleaseFix")); + } + }; + /** * Handle chart API errors * @param errorMsg - Error message from chart API @@ -500,9 +516,8 @@ export function usePanelEditor(options: UsePanelEditorOptions) { return dashboardPanelData.meta.queryFields[queryIndex]; }; - const collectFieldAliasesForQuery = (queryIndex: number): string[] => { + const collectAliasesOf = (query: any): string[] => { const aliases: string[] = []; - const query = dashboardPanelData.data.queries[queryIndex]; if (!query) return aliases; ["x", "y", "z", "breakdown"].forEach((axis) => { @@ -529,6 +544,12 @@ export function usePanelEditor(options: UsePanelEditorOptions) { return aliases; }; + // Results can land after further edits, so the aliases of the query that produced them count too. + const collectFieldAliasesForQuery = (queryIndex: number): string[] => [ + ...collectAliasesOf(dashboardPanelData.data.queries[queryIndex]), + ...collectAliasesOf(chartData.value?.queries?.[queryIndex]), + ]; + const buildAliasListForQuery = (queryIndex: number): string[] => { const query = dashboardPanelData.data.queries[queryIndex]; if (!query) return []; @@ -651,12 +672,21 @@ export function usePanelEditor(options: UsePanelEditorOptions) { // Watchers // ============================================================================ + const copyPanel = (source: any, keepQueries = false) => { + const copy = JSON.parse(JSON.stringify(source)); + if (keepQueries && chartData.value?.queries) { + copy.queryType = chartData.value.queryType; + copy.queries = JSON.parse(JSON.stringify(chartData.value.queries)); + } + return copy; + }; + // Watch for chart type changes - update chartData watch( () => dashboardPanelData.data.type, async () => { await nextTick(); - chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data)); + chartData.value = copyPanel(dashboardPanelData.data, holdQueries?.() ?? false); }, ); @@ -664,15 +694,13 @@ export function usePanelEditor(options: UsePanelEditorOptions) { watch( () => dashboardPanelData.data.config, () => { + const candidate = copyPanel(dashboardPanelData.data, holdQueries?.() ?? false); // Check if the config change requires an API call - const needsApiCall = checkIfConfigChangeRequiredApiCallOrNot( - chartData.value, - dashboardPanelData.data, - ); + const needsApiCall = checkIfConfigChangeRequiredApiCallOrNot(chartData.value, candidate); // If no API call needed, auto-apply the config change if (!needsApiCall) { - chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data)); + chartData.value = candidate; } }, { deep: true }, @@ -760,10 +788,11 @@ export function usePanelEditor(options: UsePanelEditorOptions) { * * @param data - Optional data to initialize with. If not provided, uses dashboardPanelData.data */ - const initChartData = (data?: any) => { + const initChartData = (data?: any, initOptions: InitChartDataOptions = {}) => { + const keepQueries = initOptions.keepQueries ?? false; const sourceData = data ?? dashboardPanelData.data; if (sourceData) { - chartData.value = JSON.parse(JSON.stringify(sourceData)); + chartData.value = copyPanel(sourceData, keepQueries); } else { chartData.value = {}; } @@ -772,7 +801,7 @@ export function usePanelEditor(options: UsePanelEditorOptions) { // dashboardPanelData.data, so isOutDated doesn't falsely show "chart not up // to date" on edit load. nextTick(() => { - chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data)); + chartData.value = copyPanel(dashboardPanelData.data, keepQueries); // Capture the sparkline baseline only on the first (load) init; later inits // fire on every config edit and must NOT clear the pending banner. if (!sparklineBaselineCaptured) { @@ -814,6 +843,7 @@ export function usePanelEditor(options: UsePanelEditorOptions) { // Computed isOutDated, + sparklinePendingApply, isLoading, currentPanelData, isInitialDashboardPanelData, @@ -821,6 +851,7 @@ export function usePanelEditor(options: UsePanelEditorOptions) { // Actions initChartData, runQuery, + reportValidationErrors, handleChartApiError, handleLastTriggeredAtUpdate, handleLimitNumberOfSeriesWarningMessage, diff --git a/web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts index 907d8d46af..7224a4c97a 100644 --- a/web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts +++ b/web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts @@ -23,7 +23,7 @@ describe("panelEditor types and utilities", () => { showQueryBuilder: true, showVariablesSelector: true, showLastRefreshedTime: true, - showOutdatedWarning: true, + showOutdatedWarning: false, showAddToDashboardButton: false, showQueryTypeSelector: false, showGeneratedQueryDisplay: false, diff --git a/web/src/components/dashboards/PanelEditor/types/panelEditor.ts b/web/src/components/dashboards/PanelEditor/types/panelEditor.ts index 0884cc1b7d..c8662f40e0 100644 --- a/web/src/components/dashboards/PanelEditor/types/panelEditor.ts +++ b/web/src/components/dashboards/PanelEditor/types/panelEditor.ts @@ -230,6 +230,12 @@ export interface PanelEditorProps { addToDashboardDisabledReason?: I18nText | null; + /** + * While true, chart-type and config re-renders keep the queries the chart last ran. + * @default false + */ + holdQueries?: boolean; + // ---- Chart Configuration ---- /** * Array of allowed chart types. If not specified, all chart types are allowed. @@ -518,7 +524,8 @@ export const DASHBOARD_PRESET: PanelEditorConfig = { showQueryBuilder: true, showVariablesSelector: true, showLastRefreshedTime: true, - showOutdatedWarning: true, + // Live preview runs builder edits itself; pending changes show on the Apply button. + showOutdatedWarning: false, showAddToDashboardButton: false, // Dashboard has Save button instead showQueryTypeSelector: false, showGeneratedQueryDisplay: false, diff --git a/web/src/components/dashboards/addPanel/PanelFieldList.spec.ts b/web/src/components/dashboards/addPanel/PanelFieldList.spec.ts index 34043e5b47..0f38e2ec05 100644 --- a/web/src/components/dashboards/addPanel/PanelFieldList.spec.ts +++ b/web/src/components/dashboards/addPanel/PanelFieldList.spec.ts @@ -210,6 +210,60 @@ describe("FieldList", () => { }); }); + // ── Default stream for a new dashboard panel ──────────────────────── + + describe("Default stream", () => { + afterEach(() => { + localStorage.clear(); + }); + + const mountWithoutStream = (pageKey: string, editMode = false) => { + mockReturn.dashboardPanelData.data.queries[0].fields.stream = ""; + wrapper = mountComponent({ pageKey, props: { editMode } }); + mockReturn.dashboardPanelData.meta.stream.streamResults = [...mockStreamResults]; + }; + + it("opens a new dashboard panel on the stream the last panel was saved with", async () => { + localStorage.setItem("oo_dashboard_panel_stream_logs_test-org", "error_logs"); + mountWithoutStream("dashboard"); + await flushPromises(); + expect(mockReturn.dashboardPanelData.data.queries[0].fields.stream).toBe("error_logs"); + }); + + it("falls back to the last explored logs stream", async () => { + localStorage.setItem("oo_selected_stream_logs_test-org", JSON.stringify(["error_logs"])); + mountWithoutStream("dashboard"); + await flushPromises(); + expect(mockReturn.dashboardPanelData.data.queries[0].fields.stream).toBe("error_logs"); + }); + + it("skips an internal stream and prefers one with data when nothing is remembered", async () => { + mockReturn.dashboardPanelData.data.queries[0].fields.stream = ""; + wrapper = mountComponent({ pageKey: "dashboard" }); + mockReturn.dashboardPanelData.meta.stream.streamResults = [ + { name: "_agent_signals", stream_type: "logs", stats: { doc_num: 1, doc_time_max: 9 } }, + { name: "app_logs", stream_type: "logs", stats: { doc_num: 0 } }, + { name: "error_logs", stream_type: "logs", stats: { doc_num: 3, doc_time_max: 5 } }, + ]; + await flushPromises(); + expect(mockReturn.dashboardPanelData.data.queries[0].fields.stream).toBe("error_logs"); + }); + + it("keeps the first stream when editing a saved panel", async () => { + localStorage.setItem("oo_dashboard_panel_stream_logs_test-org", "error_logs"); + mountWithoutStream("dashboard", true); + await flushPromises(); + expect(mockReturn.dashboardPanelData.data.queries[0].fields.stream).toBe("app_logs"); + }); + + it("keeps the first stream outside dashboards", async () => { + localStorage.setItem("oo_dashboard_panel_stream_logs_test-org", "error_logs"); + mountWithoutStream("build"); + await flushPromises(); + expect(mockReturn.dashboardPanelData.data.queries[0].fields.stream).toBe("app_logs"); + }); + }); + // ── Stream Type Selection ─────────────────────────────────────────── describe("Stream Type Selection", () => { diff --git a/web/src/components/dashboards/addPanel/PanelFieldList.vue b/web/src/components/dashboards/addPanel/PanelFieldList.vue index fb83d4be49..6a5fe0fa5d 100644 --- a/web/src/components/dashboards/addPanel/PanelFieldList.vue +++ b/web/src/components/dashboards/addPanel/PanelFieldList.vue @@ -403,6 +403,7 @@ import { useTheme } from "@/composables/useTheme"; import useDashboardPanelData from "@/composables/dashboard/useDashboardPanel"; import useStreams from "@/composables/useStreams"; import { applyPromqlSeed, metricsStreamsOf } from "@/utils/dashboard/promqlSeed"; +import { pickDashboardPanelStream } from "@/utils/streamPersist"; import { isAutoSeededQuery } from "@/utils/metrics/metricPanelSeed"; import { buildTypeFilterBuckets } from "@/utils/metrics/metricFamily"; import { BADGE_LABEL_KEYS, getBadgeStyle } from "@/utils/metrics/metricPalette"; @@ -536,6 +537,16 @@ const currentQueryFields = () => const STREAM_LIST_WITHOUT_STREAM = new Set(["metrics", "build"]); const isEditPanel = props.editMode; + +const defaultStreamFor = (streamType: string) => { + const streams = dashboardPanelData.meta.stream.streamResults ?? []; + if (dashboardPanelDataPageKey !== "dashboard" || isEditPanel) return streams[0]?.name; + return pickDashboardPanelStream( + store.state.selectedOrganization?.identifier, + streamType, + streams, + ); +}; let initialStreamsLoaded = false; const loadStreamsListBasedOnType = async () => { @@ -809,8 +820,9 @@ watch( dashboardPanelData.layout.currentQueryIndex ].fields.stream; } else { - dashboardPanelData.data.queries[currentIndex].fields.stream = - dashboardPanelData.meta.stream.streamResults[0]?.name; + dashboardPanelData.data.queries[currentIndex].fields.stream = defaultStreamFor( + dashboardPanelData.data.queries[currentIndex].fields.stream_type, + ); } } }, diff --git a/web/src/components/dashboards/addPanel/StreamFieldSelect.spec.ts b/web/src/components/dashboards/addPanel/StreamFieldSelect.spec.ts index c4e2ce61c8..97e64c9788 100644 --- a/web/src/components/dashboards/addPanel/StreamFieldSelect.spec.ts +++ b/web/src/components/dashboards/addPanel/StreamFieldSelect.spec.ts @@ -57,9 +57,11 @@ vi.mock("@/composables/dashboard/useDashboardPanel", () => ({ })), })); +const getStreamMock = vi.hoisted(() => vi.fn()); + vi.mock("@/composables/useStreams", () => ({ default: vi.fn(() => ({ - getStream: vi.fn(() => Promise.resolve(mockStreamSchema)), + getStream: getStreamMock, })), })); @@ -88,6 +90,7 @@ describe("StreamFieldSelect", () => { beforeEach(() => { vi.clearAllMocks(); + getStreamMock.mockImplementation(() => Promise.resolve(mockStreamSchema)); }); afterEach(() => { @@ -315,6 +318,17 @@ describe("StreamFieldSelect", () => { expect(wrapper.vm.selectValue).toContain("field2"); }); + it("does not refetch the schema when the parent passes an equal streams array", async () => { + wrapper = createWrapper(); + await flushPromises(); + const callsAfterMount = getStreamMock.mock.calls.length; + + await wrapper.setProps({ streams: defaultStreams.map((stream) => ({ ...stream })) }); + await flushPromises(); + + expect(getStreamMock.mock.calls.length).toBe(callsAfterMount); + }); + it("should refetch fields when streams change", async () => { wrapper = createWrapper(); await flushPromises(); diff --git a/web/src/components/dashboards/addPanel/StreamFieldSelect.vue b/web/src/components/dashboards/addPanel/StreamFieldSelect.vue index 4747e0ee22..097d388ea3 100644 --- a/web/src/components/dashboards/addPanel/StreamFieldSelect.vue +++ b/web/src/components/dashboards/addPanel/StreamFieldSelect.vue @@ -158,10 +158,9 @@ export default defineComponent({ } } - // Re-fetch when streams list changes - watch(() => props.streams, fetchFieldsForStreams, { + // Callers pass a fresh array on every render, so only a change in content may refetch the schema. + watch(() => JSON.stringify(props.streams ?? []), fetchFieldsForStreams, { immediate: true, - deep: true, }); return { diff --git a/web/src/components/dashboards/viewPanel/ViewPanel.vue b/web/src/components/dashboards/viewPanel/ViewPanel.vue index e9f3b29f30..4ae4dd5ebf 100644 --- a/web/src/components/dashboards/viewPanel/ViewPanel.vue +++ b/web/src/components/dashboards/viewPanel/ViewPanel.vue @@ -58,7 +58,7 @@ class="hover:bg-interactive-hover-bg h-8 transition-all duration-200" data-test="dashboard-viewpanel-refresh-interval" /> - + { }); describe("superseded runs", () => { + const supersedeRun = async (edition: { isEnterprise: string; isCloud: string }) => { + const original = { isEnterprise: config.isEnterprise, isCloud: config.isCloud }; + Object.assign(config, edition); + const streams: any[] = []; + streamOverride = (payload, callbacks) => { + streams.push({ payload, callbacks }); + return "held-stream"; + }; + try { + const loader = usePanelDataLoader( + createMockPanelSchema(), + createMockSelectedTimeObj({ + start_time: new Date(Date.now() - 3600000), + end_time: new Date(), + }), + createMockVariablesData(), + ref(null), + ref(true), + ref("dashboards"), + ref("test-dashboard"), + ref("test-folder"), + ref(null), + ref(null), + ref(null), + ref(null), + ref(null), + ref(false), + ); + await loader.loadData(); + const traceId = streams[0].payload.traceId; + expect(loader.searchRequestTraceIds.value).toContain(traceId); + vi.mocked(queryService.delete_running_queries).mockClear(); + await loader.loadData(); + return { streams, traceId }; + } finally { + Object.assign(config, original); + } + }; + + it("cancels the superseded run's search on the server before starting the next", async () => { + const { streams, traceId } = await supersedeRun({ isEnterprise: "true", isCloud: "false" }); + + expect(queryService.delete_running_queries).toHaveBeenCalledWith("test-org", [traceId]); + expect(streams).toHaveLength(2); + }); + + it("skips the server-side cancel on OSS, which has no query manager route", async () => { + const { streams } = await supersedeRun({ isEnterprise: "false", isCloud: "false" }); + + expect(queryService.delete_running_queries).not.toHaveBeenCalled(); + expect(streams).toHaveLength(2); + }); + it("a stream completing while the rerun is debounced writes nothing", async () => { const streams: any[] = []; streamOverride = (payload, callbacks) => { diff --git a/web/src/composables/dashboard/usePanelDataLoader.ts b/web/src/composables/dashboard/usePanelDataLoader.ts index d68d515809..a3e314389e 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.ts @@ -16,6 +16,7 @@ import { ref, watch, reactive, toRefs, onMounted, onUnmounted, toRaw, markRaw } from "vue"; import queryService from "../../services/search"; import { useStore } from "vuex"; +import config from "@/aws-exports"; import { usePanelCache } from "./usePanelCache"; import { isEqual, omit } from "lodash-es"; @@ -346,6 +347,25 @@ export const usePanelDataLoader = ( saveCurrentStateToCache(); }; + // Aborting the controller only stops the client; the superseded searches keep running server-side until cancelled. + const cancelSupersededSearches = () => { + const traceIds = state.searchRequestTraceIds ?? []; + if (!traceIds.length) return; + const orgId = store?.state?.selectedOrganization?.identifier; + state.searchRequestTraceIds = []; + try { + traceIds.forEach((traceId: string) => { + cancelStreamQueryBasedOnRequestId({ trace_id: traceId, org_id: orgId }); + }); + // The query manager cancel route exists only in enterprise and cloud builds; OSS answers 404. + if (config.isEnterprise == "true" || config.isCloud == "true") { + queryService.delete_running_queries(orgId, traceIds)?.catch?.(() => {}); + } + } catch (error) { + console.error("Error cancelling superseded searches:", error); + } + }; + const loadData = async () => { log("[usePanelDataLoader] " + panelSchema?.value?.title + ": loadData() PROCEEDING"); @@ -362,6 +382,7 @@ export const usePanelDataLoader = ( // Check and abort the previous call if necessary if (abortController) { log("loadData: aborting previous function call (if any)"); + cancelSupersededSearches(); abortController.abort(); } diff --git a/web/src/composables/dashboard/usePanelLivePreview.spec.ts b/web/src/composables/dashboard/usePanelLivePreview.spec.ts new file mode 100644 index 0000000000..275e1ef2b3 --- /dev/null +++ b/web/src/composables/dashboard/usePanelLivePreview.spec.ts @@ -0,0 +1,443 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { effectScope, nextTick, reactive, ref } from "vue"; +import { + hasTypedChanges, + isPopupOpen, + LIVE_PREVIEW_DEBOUNCE_MS, + LIVE_PREVIEW_HOLD_MAX_MS, + LIVE_PREVIEW_HOLD_POLL_MS, + panelPayloadSignature, + usePanelLivePreview, + variablesSignature, +} from "./usePanelLivePreview"; + +const makeQuery = (overrides: Record = {}) => ({ + query: 'SELECT histogram(_timestamp) AS "x_axis_1" FROM "default"', + customQuery: false, + vrlFunctionQuery: "", + fields: { stream: "default", stream_type: "logs", y: [{ alias: "y_axis_1", label: "Count" }] }, + config: { promql_legend: "", step_value: null }, + ...overrides, +}); + +const makePanel = () => ({ + type: "bar", + queryType: "sql", + config: { unit: "" }, + queries: [makeQuery()], +}); + +const clone = (value: any) => JSON.parse(JSON.stringify(value)); + +const setup = (opts: { valid?: boolean; overlay?: () => boolean; domOverlay?: boolean } = {}) => { + const panel = reactive(makePanel()); + const applied = ref(clone(panel)); + const liveVariables = ref([]); + const committedVariables = ref([]); + const isLoading = ref(false); + const valid = ref(opts.valid ?? true); + const pendingApply = ref(false); + const autoRun = ref(true); + const run = vi.fn(() => { + applied.value = clone(panel); + committedVariables.value = clone(liveVariables.value); + pendingApply.value = false; + }); + const scope = effectScope(); + const live = scope.run(() => + usePanelLivePreview({ + panel: () => panel, + applied: () => applied.value, + liveVariables: () => liveVariables.value, + committedVariables: () => committedVariables.value, + isBuilderValid: () => valid.value, + isLoading, + run, + isOverlayOpen: opts.domOverlay ? undefined : (opts.overlay ?? (() => false)), + pendingApply: () => pendingApply.value, + autoRun: () => autoRun.value, + }), + )!; + return { + panel, + applied, + liveVariables, + committedVariables, + isLoading, + valid, + pendingApply, + autoRun, + run, + scope, + live, + }; +}; + +describe("usePanelLivePreview", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + describe("signatures", () => { + it("ignores display-only query keys such as labels and the PromQL legend", () => { + const panel = makePanel(); + const edited = clone(panel); + edited.queries[0].fields.y[0].label = "Events"; + edited.queries[0].config.promql_legend = "{{service}}"; + expect(panelPayloadSignature(edited)).toBe(panelPayloadSignature(panel)); + }); + + it("changes when the generated query or step changes", () => { + const panel = makePanel(); + const edited = clone(panel); + edited.queries[0].query = "SELECT count(*) FROM default"; + expect(panelPayloadSignature(edited)).not.toBe(panelPayloadSignature(panel)); + const stepped = clone(panel); + stepped.queries[0].config.step_value = "30s"; + expect(panelPayloadSignature(stepped)).not.toBe(panelPayloadSignature(panel)); + }); + + it("compares variables by name and value regardless of order", () => { + expect( + variablesSignature([ + { name: "b", value: ["2", "1"], isLoading: true }, + { name: "a", value: "x" }, + ]), + ).toBe( + variablesSignature([ + { name: "a", value: "x" }, + { name: "b", value: ["1", "2"], isLoading: false }, + ]), + ); + }); + + it("flags typed custom query text and VRL edits that have not run", () => { + const applied = makePanel(); + const typed = clone(applied); + typed.queries[0].customQuery = true; + typed.queries[0].query = "SELECT * FROM default WHERE"; + expect(hasTypedChanges(typed, applied)).toBe(true); + + const vrl = clone(applied); + vrl.queries[0].vrlFunctionQuery = ".a = 1"; + expect(hasTypedChanges(vrl, applied)).toBe(true); + + const builder = clone(applied); + builder.queries[0].query = "SELECT count(*) FROM default"; + expect(hasTypedChanges(builder, applied)).toBe(false); + }); + + it("holds only for an open select list, not tooltips, menus or popovers", () => { + document.body.innerHTML = + '
tip
' + + '
' + + '
'; + expect(isPopupOpen()).toBe(false); + document.body.innerHTML += + '
'; + expect(isPopupOpen()).toBe(true); + }); + }); + + describe("live runs", () => { + it("runs an aggregation change made inside the still-open axis menu", async () => { + const { panel, run, live, scope } = setup({ domOverlay: true }); + document.body.innerHTML = + '
'; + live.arm(); + panel.queries[0].fields.y[0] = { alias: "y_axis_1", label: "Count", functionName: "sum" }; + await nextTick(); + expect(live.isPending.value).toBe(true); + panel.queries[0].query = 'SELECT sum(_timestamp) AS "y_axis_1" FROM "default"'; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("stops holding for an open select list after the cap", async () => { + const { panel, run, live, scope } = setup({ overlay: () => true }); + live.arm(); + panel.queries[0].query = "SELECT a FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS + LIVE_PREVIEW_HOLD_MAX_MS / 2); + expect(run).not.toHaveBeenCalled(); + expect(live.isPending.value).toBe(false); + vi.advanceTimersByTime(LIVE_PREVIEW_HOLD_MAX_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("does not run or turn yellow when the tab only switches Builder to Custom", async () => { + const { panel, run, live, scope } = setup(); + live.arm(); + panel.queries[0].customQuery = true; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + expect(live.isPending.value).toBe(false); + scope.stop(); + }); + + it("runs a tab 1 aggregation change while an untouched tab 2 exists", async () => { + const { panel, run, live, scope } = setup(); + live.arm(); + panel.queries.push(makeQuery()); + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).not.toHaveBeenCalled(); + + panel.queries[0].query = 'SELECT max(_timestamp) AS "y_axis_1" FROM "default"'; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("runs a tab 1 aggregation change once both tabs have run", async () => { + const { panel, applied, run, live, scope } = setup(); + panel.queries.push(makeQuery({ query: "SELECT b FROM default" })); + applied.value = clone(panel); + live.arm(); + + panel.queries[0].query = 'SELECT max(_timestamp) AS "y_axis_1" FROM "default"'; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("does not run for a new query tab until the user changes it", async () => { + const { panel, run, live, scope } = setup(); + live.arm(); + panel.queries.push(makeQuery({ query: "" })); + await nextTick(); + panel.queries[1].query = makeQuery().query; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + expect(live.isPending.value).toBe(true); + + panel.queries[1].query = "SELECT errors FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("runs once, debounced, after a burst of builder edits", async () => { + const { panel, run, live, scope } = setup(); + live.arm(); + panel.queries[0].query = "SELECT a FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS / 2); + panel.queries[0].query = "SELECT a, b FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + await nextTick(); + expect(live.isPending.value).toBe(false); + scope.stop(); + }); + + it("keeps Apply normal, not yellow, while its own live run is queued", async () => { + const { panel, run, live, scope } = setup(); + live.arm(); + panel.queries[0].query = "SELECT a FROM default"; + await nextTick(); + expect(live.isPending.value).toBe(false); + expect(live.applyState.value).toBe("refresh"); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + expect(live.isPending.value).toBe(false); + scope.stop(); + }); + + it("does nothing before it is armed (saved panel still loading)", async () => { + const { panel, run, live, scope } = setup(); + panel.queries[0].query = "SELECT normalised FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + expect(live.isPending.value).toBe(false); + scope.stop(); + }); + + it("re-renders nothing for config-only edits", async () => { + const { panel, run, live, scope } = setup(); + live.arm(); + panel.config.unit = "bytes"; + panel.queries[0].fields.y[0].label = "Events"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + scope.stop(); + }); + + it("turns Apply yellow without a live run for a change only Apply loads", async () => { + const { pendingApply, run, live, scope } = setup(); + live.arm(); + pendingApply.value = true; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + expect(live.isPending.value).toBe(true); + expect(live.applyState.value).toBe("pending"); + + pendingApply.value = false; + await nextTick(); + expect(live.isPending.value).toBe(false); + scope.stop(); + }); + + it("only marks Apply pending while Auto Run is off, then runs once it is turned back on", async () => { + const { panel, autoRun, run, live, scope } = setup(); + autoRun.value = false; + live.arm(); + panel.queries[0].query = "SELECT a FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + expect(live.isPending.value).toBe(true); + + autoRun.value = true; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("drops a scheduled run when Auto Run is turned off before it fires", async () => { + const { panel, autoRun, run, live, scope } = setup(); + live.arm(); + panel.queries[0].query = "SELECT a FROM default"; + await nextTick(); + autoRun.value = false; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + scope.stop(); + }); + + it("skips an incomplete builder and turns Apply yellow at once, asking to finish the fields", async () => { + const { panel, run, live, valid, scope } = setup({ valid: false }); + live.arm(); + panel.queries[0].query = "SELECT a FROM default"; + await nextTick(); + expect(live.isPending.value).toBe(true); + expect(live.applyState.value).toBe("incomplete"); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + + valid.value = true; + await nextTick(); + expect(live.isPending.value).toBe(false); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("runs a skipped edit once the builder turns valid without a payload change", async () => { + const { panel, run, live, valid, scope } = setup({ valid: false }); + live.arm(); + panel.queries[0].query = "SELECT a, b FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + + valid.value = true; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("pauses the whole panel while a tab has typed query text", async () => { + const { panel, run, live, scope } = setup(); + live.arm(); + panel.queries.push(makeQuery({ customQuery: true, query: "" })); + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + run.mockClear(); + + panel.queries[1].query = "SELECT * FROM default WHERE"; + panel.queries[0].query = "SELECT b FROM default"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + expect(live.isTypedPending.value).toBe(true); + expect(live.applyState.value).toBe("typed"); + scope.stop(); + }); + + it("re-runs live when a variable value changes", async () => { + const { liveVariables, run, live, scope } = setup(); + live.arm(); + liveVariables.value = [{ name: "env", value: "prod" }]; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("holds while a popup is open and fires once it closes", async () => { + let open = true; + const { panel, run, live, scope } = setup({ overlay: () => open }); + live.arm(); + panel.queries[0].query = "SELECT a FROM default WHERE level = 'er'"; + await nextTick(); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS + LIVE_PREVIEW_HOLD_POLL_MS * 3); + expect(run).not.toHaveBeenCalled(); + open = false; + vi.advanceTimersByTime(LIVE_PREVIEW_HOLD_POLL_MS); + expect(run).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it("pauses auto-runs after a run slower than 10 s and resumes after a fast one", async () => { + const { panel, run, live, isLoading, scope } = setup(); + live.arm(); + isLoading.value = true; + await nextTick(); + vi.advanceTimersByTime(11_000); + isLoading.value = false; + await nextTick(); + expect(live.isSlowPaused.value).toBe(true); + + panel.queries[0].query = "SELECT slow FROM default"; + await nextTick(); + expect(live.applyState.value).toBe("slow"); + vi.advanceTimersByTime(LIVE_PREVIEW_DEBOUNCE_MS * 2); + expect(run).not.toHaveBeenCalled(); + expect(live.applyState.value).toBe("slow"); + + isLoading.value = true; + await nextTick(); + vi.advanceTimersByTime(500); + isLoading.value = false; + await nextTick(); + expect(live.isSlowPaused.value).toBe(false); + scope.stop(); + }); + }); +}); diff --git a/web/src/composables/dashboard/usePanelLivePreview.ts b/web/src/composables/dashboard/usePanelLivePreview.ts new file mode 100644 index 0000000000..e53d440108 --- /dev/null +++ b/web/src/composables/dashboard/usePanelLivePreview.ts @@ -0,0 +1,282 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { computed, onScopeDispose, ref, toRaw, watch } from "vue"; +import type { Ref } from "vue"; +import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall"; + +export const LIVE_PREVIEW_DEBOUNCE_MS = 600; +export const LIVE_PREVIEW_SLOW_RUN_MS = 10_000; +export const LIVE_PREVIEW_HOLD_POLL_MS = 250; +export const LIVE_PREVIEW_HOLD_MAX_MS = 5_000; + +export type LivePreviewApplyState = "refresh" | "pending" | "typed" | "slow" | "incomplete"; + +export interface UsePanelLivePreviewOptions { + /** The panel being edited (dashboardPanelData.data). */ + panel: () => any; + /** The panel the chart last ran with (the editor's chartData). */ + applied: () => any; + liveVariables: () => any[]; + committedVariables: () => any[]; + /** Silent validation of the builder; never notifies. */ + isBuilderValid: () => boolean; + /** True while the chart has a query in flight. */ + isLoading: Ref; + run: () => void; + debounceMs?: number; + /** Whether a select list is open; live runs wait for the pick (capped). */ + isOverlayOpen?: () => boolean; + /** A change only Apply loads, such as a newly enabled metric sparkline; it marks Apply pending without a live run. */ + pendingApply?: () => boolean; + /** Whether edits may run on their own (Auto Run); when off they only mark Apply pending. */ + autoRun?: () => boolean; +} + +const queryPayload = (query: any) => ({ + query: query?.query ?? "", + vrl: query?.vrlFunctionQuery ?? "", + streamType: query?.fields?.stream_type ?? "", + step: query?.config?.step_value ?? null, +}); + +/** What the backend would receive for this panel, minus time range and variables. */ +export const panelPayloadSignature = (panel: any): string => + JSON.stringify({ + queryType: panel?.queryType ?? "", + queries: (panel?.queries ?? []).map(queryPayload), + }); + +export const variablesSignature = (variables: any[] | undefined): string => + JSON.stringify( + (variables ?? []) + .map((variable: any) => ({ + name: variable?.name, + value: Array.isArray(variable?.value) + ? [...variable.value].map((v) => JSON.stringify(v)).sort() + : (variable?.value ?? null), + })) + .sort((a, b) => String(a.name).localeCompare(String(b.name))), + ); + +/** Query text the user typed (custom SQL/PromQL or a VRL function) that differs from what last ran. */ +export const hasTypedChanges = (panel: any, applied: any): boolean => { + if (!applied?.queries) return false; + return (panel?.queries ?? []).some((query: any, index: number) => { + const ran = applied.queries[index]; + const vrlChanged = (query?.vrlFunctionQuery ?? "") !== (ran?.vrlFunctionQuery ?? ""); + const typedText = query?.customQuery && (query?.query ?? "").trim() !== ""; + return vrlChanged || (typedText && query.query !== ran?.query); + }); +}; + +// The Builder/Custom toggle alone changes nothing the backend receives. +const withoutEditorMode = (panel: any) => + panel && { + ...panel, + queries: (panel.queries ?? []).map((query: any) => ({ ...query, customQuery: false })), + }; + +/** An open select list (field or function picker), but not menus, popovers or tooltips. */ +export const isPopupOpen = (): boolean => + Array.from(document.querySelectorAll("[data-reka-popper-content-wrapper]")).some((wrapper) => + wrapper.querySelector('[role="listbox"]'), + ); + +/** + * Smart live preview for the dashboard panel editor: re-runs the chart when the backend + * payload of a builder edit changes, never for typed query text. + */ +export const usePanelLivePreview = (options: UsePanelLivePreviewOptions) => { + const debounceMs = options.debounceMs ?? LIVE_PREVIEW_DEBOUNCE_MS; + const isOverlayOpen = options.isOverlayOpen ?? isPopupOpen; + const autoRun = () => options.autoRun?.() ?? true; + + const isArmed = ref(false); + const isSlowPaused = ref(false); + const isRunQueued = ref(false); + let timer: ReturnType | null = null; + let runStartedAt: number | null = null; + let holdStartedAt: number | null = null; + + // Query text a newly added tab was seeded with; an untouched new tab is not a reason to run. + const tabSeeds = new WeakMap(); + const seedVersion = ref(0); + + const isUntouchedNewTab = (query: any, index: number, appliedCount: number) => { + if (index < appliedCount) return false; + const text = query?.query ?? ""; + return text.trim() === "" || tabSeeds.get(toRaw(query)) === text; + }; + + const recordTabSeeds = () => { + const appliedCount = options.applied()?.queries?.length ?? 0; + (options.panel()?.queries ?? []).forEach((query: any, index: number) => { + const raw = query && toRaw(query); + if (index < appliedCount || !raw || tabSeeds.has(raw) || query.customQuery) return; + if ((query.query ?? "").trim() === "") return; + tabSeeds.set(raw, query.query); + seedVersion.value++; + }); + }; + + const isTypedPending = computed(() => hasTypedChanges(options.panel(), options.applied())); + + const variablesChanged = computed( + () => + variablesSignature(options.liveVariables()) !== + variablesSignature(options.committedVariables()), + ); + + const payloadChanged = computed( + () => panelPayloadSignature(options.panel()) !== panelPayloadSignature(options.applied()), + ); + + /** Payload change worth a live run: ignores new tabs still holding their seeded query. */ + const liveChanged = computed(() => { + void seedVersion.value; + const panel = options.panel(); + const appliedCount = options.applied()?.queries?.length ?? 0; + const queries = (panel?.queries ?? []).filter( + (query: any, index: number) => !isUntouchedNewTab(query, index, appliedCount), + ); + return ( + panelPayloadSignature({ ...panel, queries }) !== panelPayloadSignature(options.applied()) || + variablesChanged.value + ); + }); + + const isOutdated = computed(() => { + const applied = options.applied(); + if (!applied) return false; + return ( + payloadChanged.value || + variablesChanged.value || + (options.pendingApply?.() ?? false) || + checkIfConfigChangeRequiredApiCallOrNot( + withoutEditorMode(applied), + withoutEditorMode(options.panel()), + ) + ); + }); + + const isBuilderValid = computed(() => options.isBuilderValid()); + + const isIncomplete = computed( + () => isArmed.value && !isTypedPending.value && isOutdated.value && !isBuilderValid.value, + ); + + // The chart is behind the editor, unless live preview is about to catch it up itself. + const isPending = computed( + () => isArmed.value && (isTypedPending.value || (isOutdated.value && !isRunQueued.value)), + ); + + const applyState = computed(() => { + if (isTypedPending.value && isArmed.value) return "typed"; + if (isIncomplete.value) return "incomplete"; + if (!isPending.value) return "refresh"; + if (isSlowPaused.value) return "slow"; + return "pending"; + }); + + const setTimer = (next: ReturnType | null) => { + timer = next; + isRunQueued.value = next !== null; + }; + + const clearTimer = () => { + if (timer) clearTimeout(timer); + setTimer(null); + holdStartedAt = null; + }; + + /** Restarts the run clock so a superseded run's time is not charged to the new one. */ + const markRunStarted = () => { + clearTimer(); + runStartedAt = options.isLoading.value ? Date.now() : null; + }; + + const shouldHold = () => { + if (!isOverlayOpen()) return false; + holdStartedAt ??= Date.now(); + return Date.now() - holdStartedAt < LIVE_PREVIEW_HOLD_MAX_MS; + }; + + const fire = () => { + setTimer(null); + if (isTypedPending.value || isSlowPaused.value) return; + if (shouldHold()) { + setTimer(setTimeout(fire, LIVE_PREVIEW_HOLD_POLL_MS)); + return; + } + holdStartedAt = null; + if (!options.isBuilderValid() || !liveChanged.value) return; + markRunStarted(); + options.run(); + }; + + const schedule = () => { + clearTimer(); + if (!autoRun() || isSlowPaused.value || !isBuilderValid.value) return; + setTimer(setTimeout(fire, debounceMs)); + }; + + const arm = () => { + isArmed.value = true; + }; + + watch( + () => panelPayloadSignature(options.panel()) + variablesSignature(options.liveVariables()), + () => { + recordTabSeeds(); + if (isArmed.value) schedule(); + }, + ); + + // A run skipped while the builder was incomplete is due once it is valid, even if the payload did not change. + watch(isBuilderValid, (valid) => { + if (valid && isArmed.value) schedule(); + }); + + // Turning Auto Run back on runs what changed while it was off; turning it off drops a pending run. + watch(autoRun, () => { + if (isArmed.value) schedule(); + }); + + watch(options.isLoading, (loading) => { + if (loading) { + runStartedAt ??= Date.now(); + return; + } + if (runStartedAt === null) return; + isSlowPaused.value = Date.now() - runStartedAt > LIVE_PREVIEW_SLOW_RUN_MS; + runStartedAt = null; + }); + + onScopeDispose(clearTimer); + + return { + isArmed, + isPending, + isTypedPending, + isSlowPaused, + applyState, + arm, + markRunStarted, + cancelScheduled: clearTimer, + }; +}; + +export default usePanelLivePreview; diff --git a/web/src/composables/dashboard/usePanelPromQLExecutor.ts b/web/src/composables/dashboard/usePanelPromQLExecutor.ts index cb472a93b7..b225fc64bd 100644 --- a/web/src/composables/dashboard/usePanelPromQLExecutor.ts +++ b/web/src/composables/dashboard/usePanelPromQLExecutor.ts @@ -326,7 +326,7 @@ export const usePanelPromQLExecutor = (ctx: { }); chunkProcessors[queryIndex] = chunkProcessor; - // loadData() aborts the old run's controller but never cancels its stream, so a superseded run keeps delivering frames that would overwrite the newer run's results — an empty first partition then strands the panel on "No Data". + // Frames already in flight still arrive after loadData() cancels a superseded run; they would overwrite the newer run's results and strand the panel on "No Data". const isSuperseded = () => !!abortControllerRef?.signal?.aborted; const handlePromQLResponse = (data: any, res: any) => { diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json index c0ea416edd..58508baf34 100644 --- a/web/src/locales/languages/ar-SA.json +++ b/web/src/locales/languages/ar-SA.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+ت", "chartNotUpToDate": "ملفك الطبي غير محدث", "chartOutdatedMessage": "تم تحديث تكوين المخطط / المتغيرات، لكن المخطط لم يتم تحديثه تلقائيا. انقر على زر \"تطبيق\" لتشغيل الاستعلام مرة أخرى", + "applyTooltipRefresh": "تشغيل الاستعلام مرة أخرى", + "applyTooltipPending": "لم يتم تشغيل التغييرات بعد. انقر للتشغيل الآن", + "applyTooltipTyped": "تشغيل الاستعلام المعدل", + "applyTooltipSlow": "تم إيقاف المعاينة التلقائية مؤقتًا بعد استعلام بطيء. انقر للتشغيل", + "applyTooltipIncomplete": "أكمل حقول المنشئ لتحديث المخطط", "exampleCharts": "نماذج للجداول", "namePlaceholder": "لوحة بدون عنوان", "renameHint": "انقر لإعادة تسمية هذه اللجنة", diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json index d298056c11..ba1faf8e47 100644 --- a/web/src/locales/languages/de-DE.json +++ b/web/src/locales/languages/de-DE.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Ihr Diagramm ist nicht auf dem neuesten Stand", "chartOutdatedMessage": "Diagrammkonfiguration / Variablen wurden aktualisiert, aber das Diagramm wurde nicht automatisch aktualisiert. Klicken Sie auf die Schaltfläche „Übernehmen“, um die Abfrage erneut auszuführen.", + "applyTooltipRefresh": "Abfrage erneut ausführen", + "applyTooltipPending": "Änderungen noch nicht ausgeführt. Zum Ausführen klicken", + "applyTooltipTyped": "Bearbeitete Abfrage ausführen", + "applyTooltipSlow": "Automatische Vorschau nach einer langsamen Abfrage pausiert. Zum Ausführen klicken", + "applyTooltipIncomplete": "Füllen Sie die Builder-Felder aus, um das Diagramm zu aktualisieren", "exampleCharts": "Beispieldiagramme", "namePlaceholder": "Unbenanntes Panel", "renameHint": "Klicken, um dieses Panel umzubenennen", diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index e63396eb27..712c629015 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Your chart is not up to date", "chartOutdatedMessage": "Chart Configuration / Variables has been updated, but the chart was not updated automatically. Click on the \"Apply\" button to run the query again", + "applyTooltipRefresh": "Run the query again", + "applyTooltipPending": "Changes not run yet. Click to run now", + "applyTooltipTyped": "Run the edited query", + "applyTooltipSlow": "Auto-preview paused after a slow query. Click to run", + "applyTooltipIncomplete": "Finish the builder fields to update the chart", "exampleCharts": "Example Charts", "namePlaceholder": "Untitled panel", "renameHint": "Click to rename this panel", diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json index 3c536b9112..58dd2f5f86 100644 --- a/web/src/locales/languages/es-ES.json +++ b/web/src/locales/languages/es-ES.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Su gráfico no está actualizado", "chartOutdatedMessage": "La configuración del gráfico / las variables se ha actualizado, pero el gráfico no se actualizó automáticamente. Haga clic en el botón \"Aplicar\" para ejecutar la consulta nuevamente.", + "applyTooltipRefresh": "Ejecutar la consulta de nuevo", + "applyTooltipPending": "Cambios aún no ejecutados. Haz clic para ejecutar ahora", + "applyTooltipTyped": "Ejecutar la consulta editada", + "applyTooltipSlow": "Vista previa automática en pausa tras una consulta lenta. Haz clic para ejecutar", + "applyTooltipIncomplete": "Completa los campos del generador para actualizar el gráfico", "exampleCharts": "Gráficos de ejemplo", "namePlaceholder": "Panel sin título", "renameHint": "Haga clic para cambiar el nombre de este panel", diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json index a18f6b7e97..5789258dcc 100644 --- a/web/src/locales/languages/fr-FR.json +++ b/web/src/locales/languages/fr-FR.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Votre graphique n'est pas à jour", "chartOutdatedMessage": "La configuration du graphique / les variables a été mise à jour, mais le graphique n'a pas été mis à jour automatiquement. Cliquez sur le bouton \"Appliquer\" pour réexécuter la requête.", + "applyTooltipRefresh": "Relancer la requête", + "applyTooltipPending": "Modifications non exécutées. Cliquez pour exécuter maintenant", + "applyTooltipTyped": "Exécuter la requête modifiée", + "applyTooltipSlow": "Aperçu automatique en pause après une requête lente. Cliquez pour exécuter", + "applyTooltipIncomplete": "Complétez les champs du générateur pour mettre à jour le graphique", "exampleCharts": "Exemples de graphiques", "namePlaceholder": "Panneau sans titre", "renameHint": "Cliquez pour renommer ce panneau", diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json index 15b77f1efe..47e88b8486 100644 --- a/web/src/locales/languages/it-IT.json +++ b/web/src/locales/languages/it-IT.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Il grafico non è aggiornato", "chartOutdatedMessage": "La configurazione del grafico / le variabili sono state aggiornate, ma il grafico non è stato aggiornato automaticamente. Fai clic sul pulsante \"Applica\" per eseguire nuovamente la query.", + "applyTooltipRefresh": "Esegui di nuovo la query", + "applyTooltipPending": "Modifiche non ancora eseguite. Fai clic per eseguire ora", + "applyTooltipTyped": "Esegui la query modificata", + "applyTooltipSlow": "Anteprima automatica in pausa dopo una query lenta. Fai clic per eseguire", + "applyTooltipIncomplete": "Completa i campi del generatore per aggiornare il grafico", "exampleCharts": "Grafici di esempio", "namePlaceholder": "Pannello senza titolo", "renameHint": "Fai clic per rinominare questo pannello", diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json index 407aab22dc..a39ddaf4cc 100644 --- a/web/src/locales/languages/ja-JP.json +++ b/web/src/locales/languages/ja-JP.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "グラフは最新ではありません", "chartOutdatedMessage": "チャートの構成/変数が更新されましたが、チャートは自動的に更新されませんでした。「適用」ボタンをクリックしてクエリを再度実行してください。", + "applyTooltipRefresh": "クエリを再実行", + "applyTooltipPending": "変更はまだ実行されていません。クリックして今すぐ実行", + "applyTooltipTyped": "編集したクエリを実行", + "applyTooltipSlow": "遅いクエリのため自動プレビューを一時停止しました。クリックして実行", + "applyTooltipIncomplete": "グラフを更新するにはビルダーの項目を入力してください", "exampleCharts": "サンプルチャート", "namePlaceholder": "無題のパネル", "renameHint": "クリックしてこのパネルの名前を変更", diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json index dfbd71ceff..20dfb479a9 100644 --- a/web/src/locales/languages/ko-KR.json +++ b/web/src/locales/languages/ko-KR.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "차트가 최신 상태가 아닙니다", "chartOutdatedMessage": "차트 구성/변수가 업데이트되었지만 차트가 자동으로 업데이트되지 않았습니다. \"적용\" 버튼을 클릭하여 쿼리를 다시 실행하세요.", + "applyTooltipRefresh": "쿼리 다시 실행", + "applyTooltipPending": "변경 사항이 아직 실행되지 않았습니다. 클릭하여 지금 실행", + "applyTooltipTyped": "편집한 쿼리 실행", + "applyTooltipSlow": "느린 쿼리로 인해 자동 미리보기가 일시 중지되었습니다. 클릭하여 실행", + "applyTooltipIncomplete": "차트를 업데이트하려면 빌더 필드를 완성하세요", "exampleCharts": "예제 차트", "namePlaceholder": "제목 없는 패널", "renameHint": "이 패널의 이름을 바꾸려면 클릭하세요", diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json index ff72a077ac..339c3341fa 100644 --- a/web/src/locales/languages/nl-NL.json +++ b/web/src/locales/languages/nl-NL.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Je grafiek is niet up-to-date", "chartOutdatedMessage": "Grafiekconfiguratie / variabelen zijn bijgewerkt, maar de grafiek is niet automatisch bijgewerkt. Klik op de knop \"Toepassen\" om de query opnieuw uit te voeren.", + "applyTooltipRefresh": "Query opnieuw uitvoeren", + "applyTooltipPending": "Wijzigingen nog niet uitgevoerd. Klik om nu uit te voeren", + "applyTooltipTyped": "Bewerkte query uitvoeren", + "applyTooltipSlow": "Automatisch voorbeeld gepauzeerd na een trage query. Klik om uit te voeren", + "applyTooltipIncomplete": "Vul de builder-velden aan om de grafiek bij te werken", "exampleCharts": "Voorbeeldgrafieken", "namePlaceholder": "Naamloos paneel", "renameHint": "Klik om dit paneel te hernoemen", diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json index b0e4e810ec..e81198ae4f 100644 --- a/web/src/locales/languages/pl-PL.json +++ b/web/src/locales/languages/pl-PL.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Twój wykres nie jest aktualny", "chartOutdatedMessage": "Konfiguracja wykresu / zmienne zostały zaktualizowane, ale wykres nie został zaktualizowany automatycznie. Kliknij na przycisk \"Zastosuj\", aby uruchomić zapytanie ponownie.", + "applyTooltipRefresh": "Uruchom zapytanie ponownie", + "applyTooltipPending": "Zmiany nie zostały jeszcze uruchomione. Kliknij, aby uruchomić teraz", + "applyTooltipTyped": "Uruchom edytowane zapytanie", + "applyTooltipSlow": "Automatyczny podgląd wstrzymany po wolnym zapytaniu. Kliknij, aby uruchomić", + "applyTooltipIncomplete": "Uzupełnij pola kreatora, aby zaktualizować wykres", "exampleCharts": "Przykładowe wykresy", "namePlaceholder": "Panel bez tytułu", "renameHint": "Kliknij, aby zmienić nazwę tego panelu", diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json index e6a85ec4c4..c58e63ae3c 100644 --- a/web/src/locales/languages/pt-PT.json +++ b/web/src/locales/languages/pt-PT.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Seu gráfico não está atualizado", "chartOutdatedMessage": "A configuração do gráfico / variáveis foi atualizada, mas o gráfico não foi atualizado automaticamente. Clique no botão \"Aplicar\" para executar a consulta novamente", + "applyTooltipRefresh": "Executar a consulta novamente", + "applyTooltipPending": "Alterações ainda não executadas. Clique para executar agora", + "applyTooltipTyped": "Executar a consulta editada", + "applyTooltipSlow": "Pré-visualização automática em pausa após uma consulta lenta. Clique para executar", + "applyTooltipIncomplete": "Conclua os campos do construtor para atualizar o gráfico", "exampleCharts": "Exemplos de gráficos", "namePlaceholder": "Painel sem título", "renameHint": "Clique para renomear este painel", diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json index 71f4dc12e7..674a29ee69 100644 --- a/web/src/locales/languages/ru-RU.json +++ b/web/src/locales/languages/ru-RU.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Ваш график не актуален", "chartOutdatedMessage": "Конфигурация графика / переменные были обновлены, но график не был обновлен автоматически. Нажмите кнопку \"Применить\", чтобы выполнить запрос снова.", + "applyTooltipRefresh": "Выполнить запрос снова", + "applyTooltipPending": "Изменения ещё не применены. Нажмите, чтобы выполнить сейчас", + "applyTooltipTyped": "Выполнить изменённый запрос", + "applyTooltipSlow": "Автопросмотр приостановлен после медленного запроса. Нажмите, чтобы выполнить", + "applyTooltipIncomplete": "Заполните поля конструктора, чтобы обновить график", "exampleCharts": "Примеры графиков", "namePlaceholder": "Панель без названия", "renameHint": "Нажмите, чтобы переименовать эту панель", diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json index 37f5765d52..3c7df3d187 100644 --- a/web/src/locales/languages/tr-TR.json +++ b/web/src/locales/languages/tr-TR.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Grafiğiniz güncel değil", "chartOutdatedMessage": "Grafik Yapılandırması / Değişkenler güncellendi, ancak grafik otomatik olarak güncellenmedi. Sorguyu yeniden çalıştırmak için \"Uygula\" düğmesine tıklayın.", + "applyTooltipRefresh": "Sorguyu yeniden çalıştır", + "applyTooltipPending": "Değişiklikler henüz çalıştırılmadı. Şimdi çalıştırmak için tıklayın", + "applyTooltipTyped": "Düzenlenen sorguyu çalıştır", + "applyTooltipSlow": "Yavaş bir sorgudan sonra otomatik önizleme duraklatıldı. Çalıştırmak için tıklayın", + "applyTooltipIncomplete": "Grafiği güncellemek için oluşturucu alanlarını tamamlayın", "exampleCharts": "Örnek Grafikler", "namePlaceholder": "Başlıksız Panel", "renameHint": "Bu paneli yeniden adlandırmak için tıklayın", diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json index 58e5563c2a..e1e137feea 100644 --- a/web/src/locales/languages/vi-VN.json +++ b/web/src/locales/languages/vi-VN.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "Biểu đồ của bạn chưa được cập nhật", "chartOutdatedMessage": "Cấu hình biểu đồ / Biến đã được cập nhật, nhưng biểu đồ không được cập nhật tự động. Nhấp vào nút \"Apply\" để chạy lại truy vấn.", + "applyTooltipRefresh": "Chạy lại truy vấn", + "applyTooltipPending": "Thay đổi chưa được chạy. Nhấp để chạy ngay", + "applyTooltipTyped": "Chạy truy vấn đã chỉnh sửa", + "applyTooltipSlow": "Xem trước tự động đã tạm dừng sau một truy vấn chậm. Nhấp để chạy", + "applyTooltipIncomplete": "Hoàn tất các trường trình tạo để cập nhật biểu đồ", "exampleCharts": "Biểu đồ mẫu", "namePlaceholder": "Bảng không có tiêu đề", "renameHint": "Nhấp để đổi tên bảng này", diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json index 68dcf583df..55eedd0894 100644 --- a/web/src/locales/languages/zh-CN.json +++ b/web/src/locales/languages/zh-CN.json @@ -7006,6 +7006,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "您的图表不是最新的", "chartOutdatedMessage": "图表配置/变量已更新,但图表未自动更新。点击“应用”按钮重新运行查询。", + "applyTooltipRefresh": "重新运行查询", + "applyTooltipPending": "更改尚未运行。点击立即运行", + "applyTooltipTyped": "运行已编辑的查询", + "applyTooltipSlow": "查询较慢,自动预览已暂停。点击运行", + "applyTooltipIncomplete": "请完成构建器字段以更新图表", "exampleCharts": "示例图表", "namePlaceholder": "未命名面板", "renameHint": "点击重命名此面板", diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json index 5535737e65..04b2480a69 100644 --- a/web/src/locales/languages/zh-TW.json +++ b/web/src/locales/languages/zh-TW.json @@ -7001,6 +7001,11 @@ "addTargetShort": "+T", "chartNotUpToDate": "您的圖表尚未更新", "chartOutdatedMessage": "圖表設定 / 變數已更新,但圖表並未自動更新。請點擊 \"Apply\" 按鈕以重新執行查詢。", + "applyTooltipRefresh": "重新執行查詢", + "applyTooltipPending": "變更尚未執行。點擊立即執行", + "applyTooltipTyped": "執行已編輯的查詢", + "applyTooltipSlow": "查詢較慢,自動預覽已暫停。點擊執行", + "applyTooltipIncomplete": "請完成建構器欄位以更新圖表", "exampleCharts": "範例圖表", "namePlaceholder": "未命名面板", "renameHint": "按一下以重新命名此面板", diff --git a/web/src/utils/dashboard/panelValidation.spec.ts b/web/src/utils/dashboard/panelValidation.spec.ts index 3d7b2d1fb0..b42a299e9b 100644 --- a/web/src/utils/dashboard/panelValidation.spec.ts +++ b/web/src/utils/dashboard/panelValidation.spec.ts @@ -15,6 +15,7 @@ import { describe, expect, it, vi } from "vitest"; import { + validateConditions, validateDashboardJson, validatePanel, validateSQLPanelFields, @@ -313,3 +314,41 @@ describe("validatePanel on a PromQL formula", () => { ).toHaveLength(1); }); }); + +describe("validateConditions", () => { + const t = gt as any; + + it("flags a filter added without a value, including inside groups", () => { + const errors: string[] = []; + validateConditions( + t, + [ + { + filterType: "group", + conditions: [ + { + filterType: "condition", + type: "condition", + column: "level", + operator: "=", + value: "", + }, + { filterType: "condition", type: "list", column: "job", values: [] }, + ], + }, + ], + errors, + ); + expect(errors).toHaveLength(2); + }); + + it("accepts complete conditions", () => { + const errors: string[] = []; + validateConditions( + t, + [{ filterType: "condition", type: "condition", column: "a", operator: "Is Null" }], + errors, + ); + expect(errors).toHaveLength(0); + }); +}); diff --git a/web/src/utils/dashboard/panelValidation.ts b/web/src/utils/dashboard/panelValidation.ts index 42ee4f8d1f..e986c8cbd3 100644 --- a/web/src/utils/dashboard/panelValidation.ts +++ b/web/src/utils/dashboard/panelValidation.ts @@ -120,7 +120,7 @@ const validateHistogramIntervalArgument = ( * @param conditions the conditions array * @param errors the array to push the errors to */ -function validateConditions(t: TranslateFn, conditions: any, errors: any) { +export function validateConditions(t: TranslateFn, conditions: any, errors: any) { conditions.forEach((it: any) => { if (it.filterType === "condition") { validateConditionItem(t, it, errors); @@ -758,7 +758,7 @@ const validateJoinField = (t: TranslateFn, join: any, errors: string[], joinInde }); }; -const validateJoinFields = (t: TranslateFn, joins: any, errors: string[]) => { +export const validateJoinFields = (t: TranslateFn, joins: any, errors: string[]) => { // validate join fields if (joins) { joins.forEach((join: any, index: number) => validateJoinField(t, join, errors, index)); diff --git a/web/src/utils/streamPersist.spec.ts b/web/src/utils/streamPersist.spec.ts new file mode 100644 index 0000000000..7827ed2dc6 --- /dev/null +++ b/web/src/utils/streamPersist.spec.ts @@ -0,0 +1,81 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + pickDashboardPanelStream, + restoreDashboardPanelStreamType, + saveDashboardPanelStream, + saveLogsSelectedStreams, + saveMetricsStream, + saveTracesStream, +} from "./streamPersist"; + +describe("dashboard panel stream memory", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("remembers the saved stream per org and stream type", () => { + saveDashboardPanelStream("org1", "traces", "spans"); + saveDashboardPanelStream("org1", "logs", "nginx"); + saveDashboardPanelStream("org2", "metrics", "cpu"); + + expect(restoreDashboardPanelStreamType("org1")).toBe("logs"); + expect(restoreDashboardPanelStreamType("org2")).toBe("metrics"); + expect(pickDashboardPanelStream("org1", "traces", ["default", "spans"])).toBe("spans"); + expect(pickDashboardPanelStream("org1", "logs", ["default", "nginx"])).toBe("nginx"); + }); + + it("falls back to the last explored stream of that type, then the first stream", () => { + saveLogsSelectedStreams("org1", "logs", ["missing", "app"]); + saveTracesStream("org1", "otel"); + saveMetricsStream("org1", "mem"); + + expect(pickDashboardPanelStream("org1", "logs", ["default", "app"])).toBe("app"); + expect(pickDashboardPanelStream("org1", "traces", ["default", "otel"])).toBe("otel"); + expect(pickDashboardPanelStream("org1", "metrics", ["cpu", "mem"])).toBe("mem"); + expect(pickDashboardPanelStream("org1", "metrics", ["cpu", "disk"])).toBe("cpu"); + }); + + it("falls back to the freshest stream with data, skipping internal streams", () => { + const streams = [ + { name: "_agent_signals", stats: { doc_num: 900, doc_time_max: 300 } }, + { name: "empty", stats: { doc_num: 0, doc_time_max: 0 } }, + { name: "older", stats: { doc_num: 10, doc_time_max: 100 } }, + { name: "newer", stats: { doc_num: 5, doc_time_max: 200 } }, + ]; + expect(pickDashboardPanelStream("org1", "logs", streams)).toBe("newer"); + expect( + pickDashboardPanelStream("org1", "logs", [{ name: "_internal" }, { name: "plain" }]), + ).toBe("plain"); + expect(pickDashboardPanelStream("org1", "logs", [{ name: "_internal" }])).toBe("_internal"); + }); + + it("skips a remembered stream that no longer exists", () => { + saveDashboardPanelStream("org1", "logs", "deleted"); + expect(pickDashboardPanelStream("org1", "logs", ["default"])).toBe("default"); + }); + + it("ignores unknown stream types and empty values", () => { + saveDashboardPanelStream("org1", "enrichment_tables", "geo"); + saveDashboardPanelStream("org1", "logs", ""); + expect(restoreDashboardPanelStreamType("org1")).toBe(""); + }); + + it("survives a storage that throws", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("denied"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + + expect(() => saveDashboardPanelStream("org1", "logs", "app")).not.toThrow(); + expect(restoreDashboardPanelStreamType("org1")).toBe(""); + expect(pickDashboardPanelStream("org1", "logs", ["default"])).toBe("default"); + }); +}); diff --git a/web/src/utils/streamPersist.ts b/web/src/utils/streamPersist.ts index ca502ee791..699766fc2c 100644 --- a/web/src/utils/streamPersist.ts +++ b/web/src/utils/streamPersist.ts @@ -7,6 +7,13 @@ const STORAGE_KEYS = { logsStreamType: (orgId: string) => `oo_logs_stream_type_${orgId}`, }; +const DASHBOARD_STREAM_TYPES = ["logs", "metrics", "traces"]; + +const DASHBOARD_KEYS = { + streamType: (orgId: string) => `oo_dashboard_panel_stream_type_${orgId}`, + stream: (orgId: string, streamType: string) => `oo_dashboard_panel_stream_${streamType}_${orgId}`, +}; + // Namespaced to the Logs page: the plain per-type name would collide with the Traces and Metrics string keys. const logsSelectionKey = (orgId: string, streamType: string) => `oo_logs_selected_stream_${streamType || "logs"}_${orgId}`; @@ -68,3 +75,71 @@ export function saveLogsStreamType(orgId: string, streamType: string): void { export function restoreLogsStreamType(orgId: string): string { return localStorage.getItem(STORAGE_KEYS.logsStreamType(orgId)) || "logs"; } + +const readStorage = (key: string): string => { + try { + return localStorage.getItem(key) || ""; + } catch { + return ""; + } +}; + +/** Remembers the stream a dashboard panel was saved with, per org and stream type. */ +export function saveDashboardPanelStream(orgId: string, streamType: string, stream: string): void { + if (!orgId || !stream || !DASHBOARD_STREAM_TYPES.includes(streamType)) return; + try { + localStorage.setItem(DASHBOARD_KEYS.streamType(orgId), streamType); + localStorage.setItem(DASHBOARD_KEYS.stream(orgId, streamType), stream); + } catch { + // Storage can be full or disabled; the next panel then falls back to the first stream. + } +} + +export function restoreDashboardPanelStreamType(orgId: string): string { + const streamType = readStorage(DASHBOARD_KEYS.streamType(orgId)); + return DASHBOARD_STREAM_TYPES.includes(streamType) ? streamType : ""; +} + +const lastStreamsOfType = (orgId: string, streamType: string): string[] => { + const dashboardStream = readStorage(DASHBOARD_KEYS.stream(orgId, streamType)); + let explored: string[] = []; + try { + if (streamType === "logs") explored = restoreLogsSelectedStreams(orgId, "logs"); + if (streamType === "traces") explored = [restoreTracesStream(orgId)]; + if (streamType === "metrics") explored = [restoreMetricsStream(orgId)]; + } catch { + explored = []; + } + return [dashboardStream, ...explored].filter(Boolean); +}; + +export interface DashboardStreamOption { + name: string; + stats?: { doc_num?: number; doc_time_max?: number }; +} + +const hasData = (stream: DashboardStreamOption) => + (stream.stats?.doc_num ?? 0) > 0 || (stream.stats?.doc_time_max ?? 0) > 0; + +/** + * Default stream for a new dashboard panel: last saved panel stream, then the last explored + * stream, then the most recently ingested non-internal stream, then the first one. + */ +export function pickDashboardPanelStream( + orgId: string, + streamType: string, + available: Array, +): string { + const streams = available.map((stream) => + typeof stream === "string" ? { name: stream } : stream, + ); + const names = streams.map((stream) => stream.name); + const remembered = lastStreamsOfType(orgId, streamType).find((name) => names.includes(name)); + if (remembered) return remembered; + // Underscore-prefixed streams are internal (e.g. _agent_signals) and usually empty for the user. + const userStreams = streams.filter((stream) => !stream.name.startsWith("_")); + const freshest = userStreams + .filter(hasData) + .sort((a, b) => (b.stats?.doc_time_max ?? 0) - (a.stats?.doc_time_max ?? 0))[0]; + return freshest?.name ?? userStreams[0]?.name ?? names[0] ?? ""; +} diff --git a/web/src/views/Dashboards/addPanel/AddPanel.spec.ts b/web/src/views/Dashboards/addPanel/AddPanel.spec.ts index 92151a3fd2..1727510bb0 100644 --- a/web/src/views/Dashboards/addPanel/AddPanel.spec.ts +++ b/web/src/views/Dashboards/addPanel/AddPanel.spec.ts @@ -8,6 +8,8 @@ import { isEqual } from "lodash-es"; import { addPanel, getDashboard, updatePanel } from "@/utils/commons"; import analytics from "@/services/product_analytics"; import useDashboardPanel from "@/composables/dashboard/useDashboardPanel"; +import { DEFAULT_SQL_X_FIELD, DEFAULT_SQL_Y_FIELD_COUNT } from "@/utils/dashboard/defaultFields"; +import config from "@/aws-exports"; import { createI18n } from "vue-i18n"; vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } })); @@ -4882,5 +4884,250 @@ describe("AddPanel.vue", () => { expect(notificationMocks.showErrorNotification).not.toHaveBeenCalled(); expect(wrapper.vm.errorData.errors).toEqual(["Add one field for the X-Axis"]); }); + + it("shows the errors and runs nothing when Apply is clicked on an incomplete builder", async () => { + wrapper = await mountForValidation(); + validatePanelMock.mockImplementation(() => {}); + const editor = { + reportValidationErrors: vi.fn(), + runQuery: vi.fn(), + initChartData: vi.fn(), + }; + wrapper.vm.panelEditorRef = editor; + wrapper.vm.dashboardPanelData.data.queries[0].query = ""; + + wrapper.vm.runQuery(); + + expect(editor.reportValidationErrors).toHaveBeenCalledTimes(1); + expect(editor.reportValidationErrors.mock.calls[0][0]).toContain( + "dashboard.utils.queryIsEmpty", + ); + expect(editor.runQuery).not.toHaveBeenCalled(); + expect(editor.initChartData).not.toHaveBeenCalled(); + }); + + it("still runs Apply with a filter that has no value yet, which the query drops", async () => { + wrapper = await mountForValidation(); + validatePanelMock.mockImplementation(() => {}); + const editor = { reportValidationErrors: vi.fn(), runQuery: vi.fn(), initChartData: vi.fn() }; + wrapper.vm.panelEditorRef = editor; + const query = wrapper.vm.dashboardPanelData.data.queries[0]; + // The mocked panel's map/sankey placeholders are objects; a real line panel has them null. + Object.assign(query.fields, { source: null, target: null, latitude: null, longitude: null }); + query.fields.x = [DEFAULT_SQL_X_FIELD()]; + query.fields.y = [DEFAULT_SQL_Y_FIELD_COUNT()]; + query.query = "SELECT count(*) FROM default"; + query.fields.filter = { + filterType: "group", + conditions: [ + { + filterType: "condition", + type: "condition", + column: { field: "level" }, + operator: "=", + value: "", + }, + ], + }; + wrapper.vm.shouldRefreshWithoutCache = false; + + wrapper.vm.runQuery(true); + + expect(editor.reportValidationErrors).not.toHaveBeenCalled(); + expect(wrapper.vm.shouldRefreshWithoutCache).toBe(true); + query.fields.filter = { conditions: [] }; + }); + + it("holds live runs while a filter is half-built, even one the query drops", async () => { + wrapper = await mountForValidation(); + validatePanelMock.mockImplementation(() => {}); + const query = wrapper.vm.dashboardPanelData.data.queries[0]; + Object.assign(query.fields, { source: null, target: null, latitude: null, longitude: null }); + query.fields.x = [DEFAULT_SQL_X_FIELD()]; + query.fields.y = [DEFAULT_SQL_Y_FIELD_COUNT()]; + query.query = "SELECT count(*) FROM default"; + const withFilter = (condition: Record) => ({ + filterType: "group", + conditions: [{ filterType: "condition", column: { field: "level" }, ...condition }], + }); + + query.fields.filter = withFilter({ type: "list", values: [] }); + expect(wrapper.vm.isBuilderValidSilently()).toBe(false); + query.fields.filter = withFilter({ type: "condition", operator: "=", value: "" }); + expect(wrapper.vm.isBuilderValidSilently()).toBe(false); + query.fields.filter = withFilter({ type: "list", values: ["error"] }); + expect(wrapper.vm.isBuilderValidSilently()).toBe(true); + query.fields.filter = { conditions: [] }; + }); + + it("still runs a custom SQL query that fails validation, as Apply always has", async () => { + wrapper = await mountForValidation(); + validatePanelMock.mockImplementation((errors: string[]) => { + errors.push("Alias _timestamp is not allowed"); + }); + const editor = { + reportValidationErrors: vi.fn(), + runQuery: vi.fn(), + initChartData: vi.fn(), + }; + wrapper.vm.panelEditorRef = editor; + const query = wrapper.vm.dashboardPanelData.data.queries[0]; + query.customQuery = true; + query.query = "SELECT _timestamp AS _timestamp FROM default"; + query.fields.filter = { filterType: "group", conditions: [] }; + wrapper.vm.shouldRefreshWithoutCache = false; + + wrapper.vm.runQuery(true); + + expect(editor.reportValidationErrors).not.toHaveBeenCalled(); + expect(wrapper.vm.shouldRefreshWithoutCache).toBe(true); + expect(wrapper.vm.errorData.errors).toEqual(["Alias _timestamp is not allowed"]); + query.customQuery = false; + }); + }); + + describe("Time range and Auto Run", () => { + const mountForTime = async () => { + const w = mount(AddPanel, { + global: { + plugins: [store, router, i18n], + mocks: { + $route: { query: { dashboard: "test-dashboard" }, params: {} }, + $router: { push: vi.fn(), replace: vi.fn() }, + }, + stubs: { + OPageHeader: true, + PanelEditor: true, + DateTimePickerDashboard: true, + QueryInspector: true, + AddSettingVariable: true, + ConfigDrawer: true, + }, + }, + props: { metaData: null }, + }); + await nextTick(); + return w; + }; + + // Stubs replace the template refs on every render, so the fakes go back in before each change. + const usePicker = (w: any, startTime: number) => { + w.vm.panelEditorRef = { initChartData: vi.fn() }; + w.vm.dateTimePickerRef = { + getConsumableDateTime: () => ({ startTime, endTime: startTime + 60_000 }), + refresh: vi.fn(), + }; + }; + + const pick = async (w: any, relativeTimePeriod: string, startTime: number) => { + usePicker(w, startTime); + w.vm.selectedDate = { valueType: "relative", relativeTimePeriod }; + await nextTick(); + }; + + const chartStart = (w: any) => w.vm.dashboardPanelData.meta.dateTime.start_time.getTime(); + + beforeEach(() => { + localStorage.removeItem("oo_toggle_auto_run"); + }); + + it("with Auto Run off, holds a picked range until Apply", async () => { + wrapper = await mountForTime(); + await pick(wrapper, "15m", 1_000_000); + expect(chartStart(wrapper)).toBe(1_000_000); + + await pick(wrapper, "30m", 2_000_000); + + expect(chartStart(wrapper)).toBe(1_000_000); + }); + + it("with Auto Run on, applies a picked range directly", async () => { + store.state.zoConfig.auto_query_enabled = true; + wrapper = await mountForTime(); + await pick(wrapper, "15m", 1_000_000); + + await pick(wrapper, "30m", 2_000_000); + + expect(chartStart(wrapper)).toBe(2_000_000); + }); + + it("applies a range picked while Auto Run was off once it is turned on", async () => { + wrapper = await mountForTime(); + await pick(wrapper, "15m", 1_000_000); + await pick(wrapper, "30m", 2_000_000); + + usePicker(wrapper, 2_000_000); + store.state.zoConfig.auto_query_enabled = true; + await nextTick(); + + expect(chartStart(wrapper)).toBe(2_000_000); + }); + }); + + describe("Apply menu", () => { + const mountForMenu = async () => { + const w = mount(AddPanel, { + global: { + plugins: [store, router, i18n], + mocks: { + $route: { query: { dashboard: "test-dashboard" }, params: {} }, + $router: { push: vi.fn(), replace: vi.fn() }, + }, + stubs: { + OPageHeader: true, + PanelEditor: true, + DateTimePickerDashboard: true, + QueryInspector: true, + AddSettingVariable: true, + ConfigDrawer: true, + }, + }, + props: { metaData: null }, + }); + await nextTick(); + return w; + }; + + const startQuery = async (w: any) => { + w.vm.$.provides.variablesAndPanelsDataLoadingState.searchRequestTraceIds = { + panel: ["trace-1"], + }; + await nextTick(); + }; + + afterEach(() => { + delete (config as any).isEnterprise; + }); + + it("offers the Apply menu on OSS only when Auto Run is available", async () => { + (config as any).isEnterprise = "false"; + wrapper = await mountForMenu(); + expect(wrapper.vm.showApplyMenu).toBe(false); + + store.state.zoConfig.auto_query_enabled = true; + await nextTick(); + + expect(wrapper.vm.showApplyMenu).toBe(true); + }); + + it("always offers the Apply menu on enterprise, for Refresh Cache & Apply", async () => { + (config as any).isEnterprise = "true"; + wrapper = await mountForMenu(); + expect(wrapper.vm.showApplyMenu).toBe(true); + }); + + it("turns Apply into a red Cancel while a query runs on enterprise", async () => { + (config as any).isEnterprise = "true"; + wrapper = await mountForMenu(); + await startQuery(wrapper); + expect(wrapper.vm.applyVariant).toBe("destructive"); + }); + + it("keeps Apply out of red on OSS while a query runs, which it cannot cancel", async () => { + (config as any).isEnterprise = "false"; + wrapper = await mountForMenu(); + await startQuery(wrapper); + expect(wrapper.vm.applyVariant).not.toBe("destructive"); + }); }); }); diff --git a/web/src/views/Dashboards/addPanel/AddPanel.vue b/web/src/views/Dashboards/addPanel/AddPanel.vue index 88a9c5ba5b..1d583ce365 100644 --- a/web/src/views/Dashboards/addPanel/AddPanel.vue +++ b/web/src/views/Dashboards/addPanel/AddPanel.vue @@ -80,6 +80,7 @@ along with this program. If not, see . v-model="selectedDate" ref="dateTimePickerRef" :disable="disable" + :auto-apply-dashboard="true" class="max-md:[&_.date-time-label]:hidden" @hide="setTimeForVariables" data-test="dashboard-global-date-time-picker" @@ -101,48 +102,82 @@ along with this program. If not, see . :loading="isSavingPanel" >{{ t("panel.save") }}
- + +