mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
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:
co-authored by
Abhay Padamani
github-actions[bot]
ktx-mihir
parent
d2b7f8240e
commit
6df43be42f
@@ -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) => {
|
||||
|
||||
@@ -7006,6 +7006,11 @@
|
||||
"addTargetShort": "+ت",
|
||||
"chartNotUpToDate": "ملفك الطبي غير محدث",
|
||||
"chartOutdatedMessage": "تم تحديث تكوين المخطط / المتغيرات، لكن المخطط لم يتم تحديثه تلقائيا. انقر على زر \"تطبيق\" لتشغيل الاستعلام مرة أخرى",
|
||||
"applyTooltipRefresh": "تشغيل الاستعلام مرة أخرى",
|
||||
"applyTooltipPending": "لم يتم تشغيل التغييرات بعد. انقر للتشغيل الآن",
|
||||
"applyTooltipTyped": "تشغيل الاستعلام المعدل",
|
||||
"applyTooltipSlow": "تم إيقاف المعاينة التلقائية مؤقتًا بعد استعلام بطيء. انقر للتشغيل",
|
||||
"applyTooltipIncomplete": "أكمل حقول المنشئ لتحديث المخطط",
|
||||
"exampleCharts": "نماذج للجداول",
|
||||
"namePlaceholder": "لوحة بدون عنوان",
|
||||
"renameHint": "انقر لإعادة تسمية هذه اللجنة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7006,6 +7006,11 @@
|
||||
"addTargetShort": "+T",
|
||||
"chartNotUpToDate": "グラフは最新ではありません",
|
||||
"chartOutdatedMessage": "チャートの構成/変数が更新されましたが、チャートは自動的に更新されませんでした。「適用」ボタンをクリックしてクエリを再度実行してください。",
|
||||
"applyTooltipRefresh": "クエリを再実行",
|
||||
"applyTooltipPending": "変更はまだ実行されていません。クリックして今すぐ実行",
|
||||
"applyTooltipTyped": "編集したクエリを実行",
|
||||
"applyTooltipSlow": "遅いクエリのため自動プレビューを一時停止しました。クリックして実行",
|
||||
"applyTooltipIncomplete": "グラフを更新するにはビルダーの項目を入力してください",
|
||||
"exampleCharts": "サンプルチャート",
|
||||
"namePlaceholder": "無題のパネル",
|
||||
"renameHint": "クリックしてこのパネルの名前を変更",
|
||||
|
||||
@@ -7006,6 +7006,11 @@
|
||||
"addTargetShort": "+T",
|
||||
"chartNotUpToDate": "차트가 최신 상태가 아닙니다",
|
||||
"chartOutdatedMessage": "차트 구성/변수가 업데이트되었지만 차트가 자동으로 업데이트되지 않았습니다. \"적용\" 버튼을 클릭하여 쿼리를 다시 실행하세요.",
|
||||
"applyTooltipRefresh": "쿼리 다시 실행",
|
||||
"applyTooltipPending": "변경 사항이 아직 실행되지 않았습니다. 클릭하여 지금 실행",
|
||||
"applyTooltipTyped": "편집한 쿼리 실행",
|
||||
"applyTooltipSlow": "느린 쿼리로 인해 자동 미리보기가 일시 중지되었습니다. 클릭하여 실행",
|
||||
"applyTooltipIncomplete": "차트를 업데이트하려면 빌더 필드를 완성하세요",
|
||||
"exampleCharts": "예제 차트",
|
||||
"namePlaceholder": "제목 없는 패널",
|
||||
"renameHint": "이 패널의 이름을 바꾸려면 클릭하세요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7006,6 +7006,11 @@
|
||||
"addTargetShort": "+T",
|
||||
"chartNotUpToDate": "Ваш график не актуален",
|
||||
"chartOutdatedMessage": "Конфигурация графика / переменные были обновлены, но график не был обновлен автоматически. Нажмите кнопку \"Применить\", чтобы выполнить запрос снова.",
|
||||
"applyTooltipRefresh": "Выполнить запрос снова",
|
||||
"applyTooltipPending": "Изменения ещё не применены. Нажмите, чтобы выполнить сейчас",
|
||||
"applyTooltipTyped": "Выполнить изменённый запрос",
|
||||
"applyTooltipSlow": "Автопросмотр приостановлен после медленного запроса. Нажмите, чтобы выполнить",
|
||||
"applyTooltipIncomplete": "Заполните поля конструктора, чтобы обновить график",
|
||||
"exampleCharts": "Примеры графиков",
|
||||
"namePlaceholder": "Панель без названия",
|
||||
"renameHint": "Нажмите, чтобы переименовать эту панель",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7006,6 +7006,11 @@
|
||||
"addTargetShort": "+T",
|
||||
"chartNotUpToDate": "您的图表不是最新的",
|
||||
"chartOutdatedMessage": "图表配置/变量已更新,但图表未自动更新。点击“应用”按钮重新运行查询。",
|
||||
"applyTooltipRefresh": "重新运行查询",
|
||||
"applyTooltipPending": "更改尚未运行。点击立即运行",
|
||||
"applyTooltipTyped": "运行已编辑的查询",
|
||||
"applyTooltipSlow": "查询较慢,自动预览已暂停。点击运行",
|
||||
"applyTooltipIncomplete": "请完成构建器字段以更新图表",
|
||||
"exampleCharts": "示例图表",
|
||||
"namePlaceholder": "未命名面板",
|
||||
"renameHint": "点击重命名此面板",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user