feat: live chart preview in the dashboard panel editor (#15214)

Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: ktx-mihir <mihir.patel@kiara.tech>
This commit is contained in:
ktx-vaidehi
2026-10-13 06:25:12 +00:00
committed by GitHub
co-authored by Abhay Padamani github-actions[bot] ktx-mihir
parent d2b7f8240e
commit 6df43be42f
42 changed files with 1905 additions and 145 deletions
@@ -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",
@@ -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(() => {});
}
@@ -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.
@@ -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);
}
}
@@ -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');
@@ -780,6 +780,7 @@ const props = withDefaults(defineProps<PanelEditorProps>(), {
variablesData: undefined,
dashboardData: undefined,
editMode: false,
holdQueries: false,
});
const emit = defineEmits<PanelEditorEmits>();
@@ -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,
@@ -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", () => {
@@ -60,6 +60,13 @@ export interface UsePanelEditorOptions {
selectedDate?: Ref<any>;
/** 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,
@@ -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,
@@ -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,
@@ -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", () => {
@@ -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,
);
}
}
},
@@ -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();
@@ -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 {
@@ -58,7 +58,7 @@
class="hover:bg-interactive-hover-bg h-8 transition-all duration-200"
data-test="dashboard-viewpanel-refresh-interval"
/>
<OButtonGroup>
<OButtonGroup class="shrink-0">
<OButton
v-if="config.isEnterprise == 'true' && searchRequestTraceIds.length && disable"
variant="outline-destructive"
@@ -93,7 +93,7 @@
<OButton
:variant="showApplyHint ? 'warning' : 'outline'"
size="icon-sm"
class="w-5"
class="w-5!"
:disabled="disable"
:aria-label="t('dashboard.viewDashboard.moreRefreshOptions')"
data-test="dashboard-viewpanel-refresh-options-btn"
@@ -2,6 +2,8 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { onMounted, ref } from "vue";
import { flushPromises } from "@vue/test-utils";
import { usePanelDataLoader } from "./usePanelDataLoader";
import queryService from "../../services/search";
import config from "@/aws-exports";
const RealAbortController = globalThis.AbortController;
@@ -1510,6 +1512,59 @@ describe("usePanelDataLoader", () => {
});
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) => {
@@ -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();
}
@@ -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 <http://www.gnu.org/licenses/>.
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<string, any> = {}) => ({
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<any>(clone(panel));
const liveVariables = ref<any[]>([]);
const committedVariables = ref<any[]>([]);
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 =
'<div data-reka-popper-content-wrapper><span role="tooltip">tip</span></div>' +
'<div data-reka-popper-content-wrapper><div role="menu"></div></div>' +
'<div data-reka-popper-content-wrapper><div role="dialog"></div></div>';
expect(isPopupOpen()).toBe(false);
document.body.innerHTML +=
'<div data-reka-popper-content-wrapper><div role="listbox"></div></div>';
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 =
'<div data-reka-popper-content-wrapper><div role="menu"><input /></div></div>';
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();
});
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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<boolean>;
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<typeof setTimeout> | 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<object, string>();
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<LivePreviewApplyState>(() => {
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<typeof setTimeout> | 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;
@@ -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) => {
+5
View File
@@ -7006,6 +7006,11 @@
"addTargetShort": "+ت",
"chartNotUpToDate": "ملفك الطبي غير محدث",
"chartOutdatedMessage": "تم تحديث تكوين المخطط / المتغيرات، لكن المخطط لم يتم تحديثه تلقائيا. انقر على زر \"تطبيق\" لتشغيل الاستعلام مرة أخرى",
"applyTooltipRefresh": "تشغيل الاستعلام مرة أخرى",
"applyTooltipPending": "لم يتم تشغيل التغييرات بعد. انقر للتشغيل الآن",
"applyTooltipTyped": "تشغيل الاستعلام المعدل",
"applyTooltipSlow": "تم إيقاف المعاينة التلقائية مؤقتًا بعد استعلام بطيء. انقر للتشغيل",
"applyTooltipIncomplete": "أكمل حقول المنشئ لتحديث المخطط",
"exampleCharts": "نماذج للجداول",
"namePlaceholder": "لوحة بدون عنوان",
"renameHint": "انقر لإعادة تسمية هذه اللجنة",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -7006,6 +7006,11 @@
"addTargetShort": "+T",
"chartNotUpToDate": "グラフは最新ではありません",
"chartOutdatedMessage": "チャートの構成/変数が更新されましたが、チャートは自動的に更新されませんでした。「適用」ボタンをクリックしてクエリを再度実行してください。",
"applyTooltipRefresh": "クエリを再実行",
"applyTooltipPending": "変更はまだ実行されていません。クリックして今すぐ実行",
"applyTooltipTyped": "編集したクエリを実行",
"applyTooltipSlow": "遅いクエリのため自動プレビューを一時停止しました。クリックして実行",
"applyTooltipIncomplete": "グラフを更新するにはビルダーの項目を入力してください",
"exampleCharts": "サンプルチャート",
"namePlaceholder": "無題のパネル",
"renameHint": "クリックしてこのパネルの名前を変更",
+5
View File
@@ -7006,6 +7006,11 @@
"addTargetShort": "+T",
"chartNotUpToDate": "차트가 최신 상태가 아닙니다",
"chartOutdatedMessage": "차트 구성/변수가 업데이트되었지만 차트가 자동으로 업데이트되지 않았습니다. \"적용\" 버튼을 클릭하여 쿼리를 다시 실행하세요.",
"applyTooltipRefresh": "쿼리 다시 실행",
"applyTooltipPending": "변경 사항이 아직 실행되지 않았습니다. 클릭하여 지금 실행",
"applyTooltipTyped": "편집한 쿼리 실행",
"applyTooltipSlow": "느린 쿼리로 인해 자동 미리보기가 일시 중지되었습니다. 클릭하여 실행",
"applyTooltipIncomplete": "차트를 업데이트하려면 빌더 필드를 완성하세요",
"exampleCharts": "예제 차트",
"namePlaceholder": "제목 없는 패널",
"renameHint": "이 패널의 이름을 바꾸려면 클릭하세요",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -7006,6 +7006,11 @@
"addTargetShort": "+T",
"chartNotUpToDate": "Ваш график не актуален",
"chartOutdatedMessage": "Конфигурация графика / переменные были обновлены, но график не был обновлен автоматически. Нажмите кнопку \"Применить\", чтобы выполнить запрос снова.",
"applyTooltipRefresh": "Выполнить запрос снова",
"applyTooltipPending": "Изменения ещё не применены. Нажмите, чтобы выполнить сейчас",
"applyTooltipTyped": "Выполнить изменённый запрос",
"applyTooltipSlow": "Автопросмотр приостановлен после медленного запроса. Нажмите, чтобы выполнить",
"applyTooltipIncomplete": "Заполните поля конструктора, чтобы обновить график",
"exampleCharts": "Примеры графиков",
"namePlaceholder": "Панель без названия",
"renameHint": "Нажмите, чтобы переименовать эту панель",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -7006,6 +7006,11 @@
"addTargetShort": "+T",
"chartNotUpToDate": "您的图表不是最新的",
"chartOutdatedMessage": "图表配置/变量已更新,但图表未自动更新。点击“应用”按钮重新运行查询。",
"applyTooltipRefresh": "重新运行查询",
"applyTooltipPending": "更改尚未运行。点击立即运行",
"applyTooltipTyped": "运行已编辑的查询",
"applyTooltipSlow": "查询较慢,自动预览已暂停。点击运行",
"applyTooltipIncomplete": "请完成构建器字段以更新图表",
"exampleCharts": "示例图表",
"namePlaceholder": "未命名面板",
"renameHint": "点击重命名此面板",
+5
View File
@@ -7001,6 +7001,11 @@
"addTargetShort": "+T",
"chartNotUpToDate": "您的圖表尚未更新",
"chartOutdatedMessage": "圖表設定 / 變數已更新,但圖表並未自動更新。請點擊 \"Apply\" 按鈕以重新執行查詢。",
"applyTooltipRefresh": "重新執行查詢",
"applyTooltipPending": "變更尚未執行。點擊立即執行",
"applyTooltipTyped": "執行已編輯的查詢",
"applyTooltipSlow": "查詢較慢,自動預覽已暫停。點擊執行",
"applyTooltipIncomplete": "請完成建構器欄位以更新圖表",
"exampleCharts": "範例圖表",
"namePlaceholder": "未命名面板",
"renameHint": "按一下以重新命名此面板",
@@ -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);
});
});
+2 -2
View File
@@ -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));
+81
View File
@@ -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");
});
});
+75
View File
@@ -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 | DashboardStreamOption>,
): 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] ?? "";
}
@@ -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<string, any>) => ({
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");
});
});
});
+258 -104
View File
@@ -80,6 +80,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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 <http://www.gnu.org/licenses/>.
:loading="isSavingPanel"
>{{ t("panel.save") }}</OButton
>
<template v-if="!['html', 'markdown'].includes(dashboardPanelData.data.type)">
<OButtonGroup
v-if="!['html', 'markdown'].includes(dashboardPanelData.data.type)"
radius="lg"
>
<OButton
v-if="config.isEnterprise === 'false'"
variant="primary"
:variant="applyVariant"
size="sm-action"
:class="{ 'border-e-button-group-divider': showApplyMenu }"
data-test="dashboard-apply"
:loading="searchRequestTraceIds.length > 0"
:disabled="searchRequestTraceIds.length > 0"
@click="() => runQuery(false)"
>{{ t("panel.apply") }}</OButton
>{{ t("panel.apply") }}
<OTooltip :content="applyTooltip" shortcut-id="panelEditorRun" />
</OButton>
<OButton
v-else
:data-test="
searchRequestTraceIds.length > 0 ? 'dashboard-cancel' : 'dashboard-apply'
"
:variant="applyVariant"
size="sm-action"
class="border-e-button-group-divider"
@click="onApplyBtnClick"
>
<OButtonGroup v-if="config.isEnterprise === 'true'" radius="lg">
<OButton
:data-test="
searchRequestTraceIds.length > 0 ? 'dashboard-cancel' : 'dashboard-apply'
"
:variant="searchRequestTraceIds.length > 0 ? 'destructive' : 'primary'"
size="sm-action"
@click="onApplyBtnClick"
>
{{ searchRequestTraceIds.length > 0 ? t("panel.cancel") : t("panel.apply") }}
</OButton>
{{ searchRequestTraceIds.length > 0 ? t("panel.cancel") : t("panel.apply") }}
<OTooltip
v-if="searchRequestTraceIds.length === 0"
:content="applyTooltip"
shortcut-id="panelEditorRun"
/>
</OButton>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
:variant="searchRequestTraceIds.length > 0 ? 'destructive' : 'primary'"
size="icon-sm"
class="h-8.5!"
:disabled="searchRequestTraceIds.length > 0"
icon-left="keyboard-arrow-down"
/>
</template>
<ODropdownItem @select="runQuery(true)">
<div class="flex items-center gap-2">
<OIcon name="refresh" size="xs" />
<span>{{ t("dashboard.addPanel.refreshCacheAndApply") }}</span>
</div>
<ODropdown v-if="showApplyMenu" side="bottom" align="end">
<template #trigger>
<OButton
:variant="applyVariant"
size="icon-sm"
class="h-8.5!"
:disabled="searchRequestTraceIds.length > 0"
data-test="dashboard-apply-options-btn"
icon-left="keyboard-arrow-down"
/>
</template>
<ODropdownItem v-if="config.isEnterprise === 'true'" @select="runQuery(true)">
<div class="flex items-center gap-2">
<OIcon name="refresh" size="xs" />
<span>{{ t("dashboard.addPanel.refreshCacheAndApply") }}</span>
</div>
</ODropdownItem>
<template v-if="isAutoRunAvailable">
<ODropdownSeparator v-if="config.isEnterprise === 'true'" />
<ODropdownItem
data-test="dashboard-panel-auto-run-toggle-btn"
@select="toggleAutoRun"
>
<template #icon-left>
<OIcon
:name="isAutoRunOn ? 'autorenew' : 'sync-disabled'"
size="sm"
:class="isAutoRunOn ? 'text-accent' : ''"
/>
</template>
<span>
<div class="font-medium">
{{ isAutoRunOn ? t("search.turnOffLiveMode") : t("search.turnOnLiveMode") }}
</div>
<div class="text-text-secondary text-xs">
{{ t("search.liveModeTooltip") }}
</div>
</span>
</ODropdownItem>
</ODropdown>
</OButtonGroup>
</template>
</template>
</ODropdown>
</OButtonGroup>
</template>
<template #actions-overflow>
<template v-if="isMobile">
@@ -179,6 +214,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="panelEditorRef"
pageType="dashboard"
:editMode="editMode"
:holdQueries="livePreview.isTypedPending.value"
:dashboardData="dashboardDataForPanelEditor"
:variablesData="updatedVariablesData"
:selectedDateTime="dateTimeForVariables || dashboardPanelData.meta.dateTime"
@@ -262,6 +298,14 @@ import { isQueryVrlEnabled } from "@/composables/dashboard/useVrlFunction";
import useAiChat from "@/composables/useAiChat";
import useStreams from "@/composables/useStreams";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
import { usePanelLivePreview } from "@/composables/dashboard/usePanelLivePreview";
import { useAutoRunToggle } from "@/composables/dashboard/useAutoRunToggle";
import {
validateConditions,
validateJoinFields,
validateSQLPanelFields,
} from "@/utils/dashboard/panelValidation";
import { restoreDashboardPanelStreamType, saveDashboardPanelStream } from "@/utils/streamPersist";
import { panelIdToBeRefreshed } from "@/utils/dashboard/convertCustomChartData";
import { createDashboardsContextProvider, contextRegistry } from "@/composables/contextProviders";
import { useVariablesManager } from "@/composables/dashboard/useVariablesManager";
@@ -280,6 +324,7 @@ import { buildPanelAutoName } from "@/utils/autoName";
import { makeAddPanelSchema, type AddPanelForm } from "./AddPanel.schema";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue";
@@ -300,6 +345,7 @@ export default defineComponent({
OFormInlineEdit,
ODropdown,
ODropdownItem,
ODropdownSeparator,
OTooltip,
DateTimePickerDashboard,
AddSettingVariable,
@@ -529,7 +575,9 @@ export default defineComponent({
if (editMode.value || !isInitialDashboardPanelData()) {
// Copy the panel data to trigger chart render with initial variables
chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data));
panelEditorRef.value?.initChartData(dashboardPanelData.data);
panelEditorRef.value?.initChartData(dashboardPanelData.data, {
keepQueries: livePreview.isTypedPending.value,
});
}
}
// After initial load, don't return - we still need to update URL params below
@@ -597,6 +645,8 @@ export default defineComponent({
if (isUnsavedTrackingActive && panelBaseline === null) {
panelBaseline = JSON.parse(JSON.stringify(dashboardPanelData.data));
}
// A saved panel's load-time normalisation is not an edit, so it must not trigger a run.
if (isUnsavedTrackingActive) livePreview.arm();
};
const hasUnsavedChanges = () =>
@@ -686,8 +736,15 @@ export default defineComponent({
} else {
editMode.value = false;
resetDashboardPanelDataAndAddTimeField();
const lastStreamType = restoreDashboardPanelStreamType(
store.state.selectedOrganization.identifier,
);
if (lastStreamType) {
dashboardPanelData.data.queries[0].fields.stream_type = lastStreamType;
}
// Initialize PanelEditor's chartData as empty for new panel
panelEditorRef.value?.initChartData({});
livePreview.arm();
// set the value of the date time after the reset
updateDateTime();
}
@@ -912,76 +969,15 @@ export default defineComponent({
);
};
const isOutDated = computed(() => {
//check that is it addpanel initial call
if (isInitialDashboardPanelData() && !editMode.value) return false;
// chartData not yet initialized — don't show "not up to date" banner
if (!chartData.value) return false;
//compare chartdata and dashboardpaneldata and variables data as well
const normalizeVariables = (obj: any) => {
const normalized = JSON.parse(JSON.stringify(obj));
// Sort arrays to ensure consistent ordering
if (normalized.values && Array.isArray(normalized.values)) {
normalized.values = normalized.values
.map((variable: any) => {
if (Array.isArray(variable.value)) {
variable.value.sort((a: any, b: any) =>
JSON.stringify(a).localeCompare(JSON.stringify(b)),
);
}
return variable;
})
.sort((a: any, b: any) => a.name.localeCompare(b.name));
}
return normalized;
};
// Get LIVE variables from variablesManager
let liveVariables: any = { values: [] };
if (variablesManager && variablesManager.variablesData.isInitialized) {
const mergedVars = variablesManager.getVariablesForPanel(
currentPanelId.value,
currentTabId.value || "",
);
liveVariables = {
isVariablesLoading: variablesManager.isLoading.value,
values: mergedVars,
};
} else {
liveVariables = variablesData;
}
const normalizedCurrent = normalizeVariables(liveVariables);
const normalizedRefreshed = normalizeVariables(updatedVariablesData);
const variablesChanged = !isEqual(normalizedCurrent, normalizedRefreshed);
const configChanged = !isEqual(
JSON.parse(JSON.stringify(chartData.value ?? {})),
JSON.parse(JSON.stringify(dashboardPanelData.data ?? {})),
);
let configNeedsApiCall = false;
if (configChanged) {
configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot(
chartData.value,
dashboardPanelData.data,
);
}
return configNeedsApiCall || variablesChanged;
});
watch(isOutDated, () => {
window.dispatchEvent(new Event("resize"));
});
watch(
() => dashboardPanelData.data.type,
async () => {
await nextTick();
chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data));
panelEditorRef.value?.initChartData(dashboardPanelData.data);
panelEditorRef.value?.initChartData(dashboardPanelData.data, {
keepQueries: livePreview.isTypedPending.value,
});
refreshDateTime();
},
);
const dateTimeForVariables = ref<{
@@ -1000,8 +996,21 @@ export default defineComponent({
end_time: endTime,
};
};
// A relative range is the same selection whatever "now" it last ended at.
const dateSelectionKey = (date: any) =>
date?.valueType === "relative"
? `relative:${date.relativeTimePeriod}`
: `absolute:${date?.startTime}:${date?.endTime}`;
const appliedDateKey = ref<string | null>(null);
const isTimeChangePending = computed(
() =>
appliedDateKey.value !== null &&
dateSelectionKey(selectedDate.value) !== appliedDateKey.value,
);
// With Auto Run off a picked range waits for Apply like any other edit; the first range always applies.
watch(selectedDate, () => {
updateDateTime();
if (isAutoRunOn.value || appliedDateKey.value === null) updateDateTime();
});
// Watch for panel-level time configuration changes and update URL
watch(
@@ -1078,8 +1087,16 @@ export default defineComponent({
// PanelEditor.runQuery shows the toast on Apply.
isValid(true, true, false);
// An incomplete builder tab must not run; typed queries run as before.
const builderErrors = collectBuilderTabErrors();
if (builderErrors.length) {
panelEditorRef.value?.reportValidationErrors(builderErrors);
return;
}
// should use cache flag
shouldRefreshWithoutCache.value = withoutCache;
livePreview.markRunStarted();
// Commit the current variable values to updatedVariablesData
// This is what the chart will use for the query
@@ -1088,9 +1105,7 @@ export default defineComponent({
// copy the data object excluding the reactivity
chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data));
panelEditorRef.value?.initChartData(dashboardPanelData.data);
// refresh the date time based on current time if relative date is selected
dateTimePickerRef.value && dateTimePickerRef.value.refresh();
updateDateTime();
refreshDateTime();
// Call PanelEditor's runQuery if available
if (panelEditorRef.value) {
@@ -1137,10 +1152,13 @@ export default defineComponent({
// Set the effective time for chart rendering
dashboardPanelData.meta.dateTime = effectiveTime;
appliedDateKey.value = dateSelectionKey(selectedDate.value);
// CRITICAL: Update chartData to trigger PanelSchemaRenderer to re-render
chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data));
panelEditorRef.value?.initChartData(dashboardPanelData.data);
panelEditorRef.value?.initChartData(dashboardPanelData.data, {
keepQueries: livePreview.isTypedPending.value,
});
// Variables also use the global time in add/edit mode
dateTimeForVariables.value = {
@@ -1156,6 +1174,12 @@ export default defineComponent({
}
};
// A relative range such as "Past 15 Minutes" must end now for every run, not only on Apply.
const refreshDateTime = () => {
dateTimePickerRef.value?.refresh();
updateDateTime();
};
const goBack = async () => {
// Clean up variables created during this session (on discard)
// Remove variables that were created in this session from the dashboard data
@@ -1265,6 +1289,18 @@ export default defineComponent({
}
};
const rememberPanelStream = () => {
const fields = dashboardPanelData.data.queries?.find(
(query: any) => query?.fields?.stream,
)?.fields;
if (!fields) return;
saveDashboardPanelStream(
store.state.selectedOrganization.identifier,
fields.stream_type,
fields.stream,
);
};
const savePanelChangesToDashboard = async (dashId: string) => {
// Left generic: these errors are never cleared before this guard, so they can be stale.
if (dashboardPanelData.data.type === "custom_chart" && errorData.errors.length > 0) {
@@ -1400,6 +1436,7 @@ export default defineComponent({
return;
}
}
rememberPanelStream();
analytics.track("dashboard_panel_saved", {
chart_type: dashboardPanelData.data.type,
is_new: !editMode.value,
@@ -1558,7 +1595,9 @@ export default defineComponent({
if (!configNeedsApiCall) {
chartData.value = JSON.parse(JSON.stringify(newVal));
panelEditorRef.value?.initChartData(newVal);
panelEditorRef.value?.initChartData(newVal, {
keepQueries: livePreview.isTypedPending.value,
});
window.dispatchEvent(new Event("resize"));
}
@@ -1635,6 +1674,113 @@ export default defineComponent({
return dashboardPanelData.data.id || "current_panel";
});
const getLiveVariables = (): any[] => {
if (variablesManager && variablesManager.variablesData.isInitialized) {
return variablesManager.getVariablesForPanel(
currentPanelId.value,
currentTabId.value || "",
);
}
return variablesData.values ?? [];
};
// Only builder-mode tabs; typed SQL/PromQL keeps the toast-then-run behaviour of Apply.
const collectBuilderTabErrors = (): string[] => {
const errors: string[] = [];
const panel = dashboardPanelData.data;
panel.queries.forEach((query: any, index: number) => {
if (query?.customQuery) return;
validateSQLPanelFields(
t,
panel,
index,
t("panel.xAxisShort"),
t("panel.yAxisShort"),
errors,
true,
"dashboard",
);
if (panel.queryType !== "promql") validateJoinFields(t, query?.joins, errors);
if ((query?.query ?? "").trim() === "") {
errors.push(t("dashboard.utils.queryIsEmpty", { index: index + 1 }));
}
});
return errors;
};
// Collected locally so the live check never writes the shared error list or toasts.
const collectBuilderErrors = (): string[] => {
const errors: string[] = [];
validatePanel(errors, true);
// A half-built filter waits even when the SQL drops it, or the live run would show unfiltered data.
dashboardPanelData.data.queries.forEach((query: any) => {
if (query?.customQuery) return;
validateConditions(t, query?.fields?.filter?.conditions ?? [], errors);
});
return [...errors, ...collectBuilderTabErrors()];
};
const isBuilderValidSilently = () => collectBuilderErrors().length === 0;
const runLivePreview = () => {
updateCommittedVariables();
chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data));
panelEditorRef.value?.initChartData(dashboardPanelData.data);
refreshDateTime();
// The editor's own run records what was loaded (e.g. the sparkline), so Apply stops showing it as pending.
panelEditorRef.value?.runQuery();
};
// Same switch as the dashboard's Auto Run: ZO_AUTO_QUERY_ENABLED plus the user's on/off choice.
const { isAutoRunAvailable, isAutoRunOn, toggleAutoRun } = useAutoRunToggle();
const livePreview = usePanelLivePreview({
panel: () => dashboardPanelData.data,
applied: () => panelEditorRef.value?.chartData,
liveVariables: getLiveVariables,
committedVariables: () => updatedVariablesData.values,
isBuilderValid: isBuilderValidSilently,
isLoading: disable,
run: runLivePreview,
pendingApply: () =>
(panelEditorRef.value?.sparklinePendingApply ?? false) || isTimeChangePending.value,
autoRun: () => isAutoRunOn.value,
});
// Turning Auto Run back on applies a range picked while it was off.
watch(isAutoRunOn, (on) => {
if (on && isTimeChangePending.value) refreshDateTime();
});
const isOutDated = computed(() => livePreview.isPending.value);
const applyVariant = computed(() => {
// Only enterprise can cancel a query, so only there does Apply turn into a red Cancel.
if (config.isEnterprise === "true" && searchRequestTraceIds.value.length > 0) {
return "destructive";
}
return livePreview.isPending.value ? "warning" : "primary";
});
const showApplyMenu = computed(
() => config.isEnterprise === "true" || isAutoRunAvailable.value,
);
const applyTooltip = computed(() => {
switch (livePreview.applyState.value) {
case "typed":
return t("panel.applyTooltipTyped");
case "slow":
return t("panel.applyTooltipSlow");
case "pending":
return t("panel.applyTooltipPending");
case "incomplete":
return t("panel.applyTooltipIncomplete");
default:
return t("panel.applyTooltipRefresh");
}
});
/**
* Opens the Add Variable panel
*/
@@ -1820,6 +1966,14 @@ export default defineComponent({
dateTimeForVariables,
seriesData,
onApplyBtnClick,
livePreview,
isBuilderValidSilently,
isAutoRunAvailable,
isAutoRunOn,
toggleAutoRun,
showApplyMenu,
applyVariant,
applyTooltip,
shouldRefreshWithoutCache,
maxQueryRangeWarning,
limitNumberOfSeriesWarningMessage,