From cd27da87a96d3cc2428f683805adeea50bf6b698 Mon Sep 17 00:00:00 2001 From: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:22:06 +0530 Subject: [PATCH] feat(dashboards): build panels on enrichment table streams [v0.40] (#16225) --- .github/workflows/playwright.yml | 1 + .../dashboardPages/dashboard-panel-actions.js | 31 +++ .../dashboard-enrichment-table-panel.spec.js | 177 ++++++++++++++++++ .../utils/enrichmentTableHelpers.js | 176 +++++++++++++++++ .../dashboards/PanelContainer.spec.ts | 45 +++++ .../components/dashboards/PanelContainer.vue | 11 ++ .../dashboards/PanelSchemaRenderer.spec.ts | 145 ++++++++++++++ .../dashboards/PanelSchemaRenderer.vue | 19 +- .../dashboards/addPanel/FieldList.spec.ts | 32 +++- .../dashboards/addPanel/FieldList.vue | 10 +- .../dashboard/usePanelDataLoader.spec.ts | 127 ++++++++++++- .../dashboard/usePanelDataLoader.ts | 16 ++ .../composables/useStreamingSearch.spec.ts | 22 +++ web/src/composables/useStreamingSearch.ts | 10 +- web/src/composables/useStreams.spec.ts | 16 ++ web/src/composables/useStreams.ts | 3 +- web/src/locales/languages/de.json | 1 + web/src/locales/languages/en.json | 1 + web/src/locales/languages/es.json | 1 + web/src/locales/languages/fr.json | 1 + web/src/locales/languages/it.json | 1 + web/src/locales/languages/ja.json | 1 + web/src/locales/languages/ko.json | 1 + web/src/locales/languages/nl.json | 1 + web/src/locales/languages/pt.json | 1 + web/src/locales/languages/tr.json | 1 + web/src/locales/languages/zh.json | 1 + .../utils/dashboard/streamLifetime.spec.ts | 91 +++++++++ web/src/utils/dashboard/streamLifetime.ts | 53 ++++++ 29 files changed, 986 insertions(+), 10 deletions(-) create mode 100644 tests/ui-testing/playwright-tests/dashboards/dashboard-enrichment-table-panel.spec.js create mode 100644 tests/ui-testing/playwright-tests/dashboards/utils/enrichmentTableHelpers.js create mode 100644 web/src/utils/dashboard/streamLifetime.spec.ts create mode 100644 web/src/utils/dashboard/streamLifetime.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0f6638304c..3d7910a533 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -203,6 +203,7 @@ jobs: "visualize-vrl.spec.js", "dashboard-series-color-multiwindow.spec.js", "dashboard-metric-camelcase.spec.js", + "dashboard-enrichment-table-panel.spec.js", ] - testfolder: "Dashboards-Streaming" browser: "chrome" diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js b/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js index 997657cc08..f5048d915f 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js @@ -169,4 +169,35 @@ export default class DashboardactionPage { getDashboardRow(dashboardName) { return this.page.getByRole("row", { name: new RegExp(`.*${dashboardName}`) }); } + + // The chart is canvas-painted, so read the series ChartRenderer feeds to ECharts via `_vnode` (`__vueParentComponent` is dev-only). + async getChartSeries(scopeSelector = '[data-test="chart-renderer"]') { + return this.page.evaluate((scopeSel) => { + const scopes = Array.from(document.querySelectorAll(scopeSel)); + const series = []; + const visited = new Set(); + const walk = (node, depth) => { + if (!node || typeof node !== "object" || depth > 400 || visited.has(node)) return; + visited.add(node); + const comp = node.component; + if (comp) { + const host = comp.setupState?.chartRef; + const options = comp.props?.data?.options; + if (options && host instanceof Element && scopes.some((scope) => scope.contains(host))) { + for (const s of options.series ?? []) { + const values = (s?.data ?? []).map((point) => + Array.isArray(point) ? point[point.length - 1] : (point?.value ?? point), + ); + series.push({ name: s?.name ? String(s.name) : "", values }); + } + } + walk(comp.subTree, depth + 1); + } + if (node.suspense) walk(node.suspense.activeBranch, depth + 1); + if (Array.isArray(node.children)) for (const c of node.children) walk(c, depth + 1); + }; + walk(document.querySelector("#app")?._vnode, 0); + return series; + }, scopeSelector); + } } diff --git a/tests/ui-testing/playwright-tests/dashboards/dashboard-enrichment-table-panel.spec.js b/tests/ui-testing/playwright-tests/dashboards/dashboard-enrichment-table-panel.spec.js new file mode 100644 index 0000000000..a1ea461a2b --- /dev/null +++ b/tests/ui-testing/playwright-tests/dashboards/dashboard-enrichment-table-panel.spec.js @@ -0,0 +1,177 @@ +// Copyright 2026 OpenObserve Inc. + +// Enrichment rows carry their upload time, so a panel must show them on any dashboard range (#15875). + +const { test, expect, navigateToBase } = require("../utils/enhanced-baseFixtures.js"); +const testLogger = require("../utils/test-logger.js"); +import PageManager from "../../pages/page-manager"; +import { cleanupTestDashboard, setupTestDashboard } from "./utils/dashCreation.js"; +import { + addCountYAxis, + addEnrichmentPanel, + deleteEnrichmentTable, + expectFirstColumn, + expectPlottedCount, + generateDashboardName, + openOnLastWeek, + setCustomSQL, + trackResultSchemaRequests, + uploadEnrichmentTable, + waitForCustomQueryAxes, + waitForRowCount, + waitForTablePanelWithData, +} from "./utils/enrichmentTableHelpers.js"; + +const INITIAL_CSV = "country,city,population\nIndia,Bengaluru,13\nUSA,Austin,1\nGermany,Berlin,4"; +const REPLACED_CSV = "country,city,population\nJapan,Tokyo,14\nBrazil,Recife,2"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Dashboard panel on an enrichment table", () => { + let tableName; + + test.beforeEach(async ({ page }) => { + tableName = `e2e_enrich_panel_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await uploadEnrichmentTable(page, tableName, INITIAL_CSV); + await waitForRowCount(page, tableName, 3); + await navigateToBase(page); + }); + + test.afterEach(async ({ page }) => { + await deleteEnrichmentTable(page, tableName); + }); + + test( + "TC-ENR-001: builder table panel shows every row and keeps them on a range before the upload", + { tag: ["@dashboard-enrichment-table", "@smoke", "@P0"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await addEnrichmentPanel(page, pm, dashboardName, tableName, "table"); + await addCountYAxis(pm); + await pm.chartTypeSelector.removeField("x_axis_1", "x"); + await pm.chartTypeSelector.searchAndAddField("country", "x"); + await pm.dashboardPanelActions.addPanelName("Countries"); + await pm.dashboardPanelActions.applyDashboardBtn(); + await waitForTablePanelWithData(page); + await expectFirstColumn(page, ["Germany", "India", "USA"]); + + await pm.dashboardPanelActions.savePanelAndWaitForNavigation(); + await waitForTablePanelWithData(page); + + await openOnLastWeek(page, tableName); + await waitForTablePanelWithData(page); + await expectFirstColumn(page, ["Germany", "India", "USA"]); + testLogger.info("Enrichment table panel renders on a range before the upload"); + + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "TC-ENR-002: custom SQL panel on an enrichment table", + { tag: ["@dashboard-enrichment-table", "@P0"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + const expectNoFailedResultSchema = trackResultSchemaRequests(page); + + await addEnrichmentPanel(page, pm, dashboardName, tableName, "table"); + await setCustomSQL( + page, + pm, + `SELECT city AS x_axis_1, count(_timestamp) AS y_axis_1 FROM "${tableName}" GROUP BY x_axis_1`, + ); + await waitForCustomQueryAxes(page, "x_axis_1", "y_axis_1"); + await pm.dashboardPanelActions.addPanelName("Cities"); + await pm.dashboardPanelActions.applyDashboardBtn(); + await waitForTablePanelWithData(page); + await expectFirstColumn(page, ["Austin", "Bengaluru", "Berlin"]); + + await pm.dashboardPanelActions.savePanelAndWaitForNavigation(); + await waitForTablePanelWithData(page); + await expectNoFailedResultSchema(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "TC-ENR-003: builder bar chart of upload counts renders, also on a range before the upload", + { tag: ["@dashboard-enrichment-table", "@P1"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await addEnrichmentPanel(page, pm, dashboardName, tableName, "bar"); + await addCountYAxis(pm); + await pm.dashboardPanelActions.addPanelName("Uploads"); + await pm.dashboardPanelActions.applyDashboardBtn(); + await pm.dashboardPanelActions.verifyChartRenders(expect); + await expect(page.locator('[data-test="no-data"]')).toBeHidden({ timeout: 30000 }); + await expectPlottedCount(pm, 3); + + await pm.dashboardPanelActions.savePanelAndWaitForNavigation(); + await openOnLastWeek(page, tableName); + await pm.dashboardPanelActions.verifyChartRenders(expect); + await expectPlottedCount(pm, 3); + + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "TC-ENR-004: re-uploading the table replaces the rows the panel shows", + { tag: ["@dashboard-enrichment-table", "@P1"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await addEnrichmentPanel(page, pm, dashboardName, tableName, "table"); + await addCountYAxis(pm); + await pm.chartTypeSelector.removeField("x_axis_1", "x"); + await pm.chartTypeSelector.searchAndAddField("country", "x"); + await pm.dashboardPanelActions.addPanelName("Countries"); + await pm.dashboardPanelActions.applyDashboardBtn(); + await waitForTablePanelWithData(page); + await pm.dashboardPanelActions.savePanelAndWaitForNavigation(); + await waitForTablePanelWithData(page); + await expectFirstColumn(page, ["Germany", "India", "USA"]); + + await uploadEnrichmentTable(page, tableName, REPLACED_CSV); + await waitForRowCount(page, tableName, 2); + await pm.dashboardTimeRefresh.refreshDashboard(); + await waitForTablePanelWithData(page); + await expectFirstColumn(page, ["Brazil", "Japan"]); + + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "TC-ENR-005: the dashboard Add Panel stream type dropdown offers enrichment_tables", + { tag: ["@dashboard-enrichment-table", "@P1"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupTestDashboard(page, pm, dashboardName); + await pm.dashboardCreate.addPanel(); + await page.locator('[data-test="index-dropdown-stream_type"]').click(); + const options = page.getByRole("listbox").getByRole("option"); + await expect(options).toHaveText(["logs", "metrics", "traces", "enrichment_tables"]); + await page.keyboard.press("Escape"); + + await pm.chartTypeSelector.selectStreamType("enrichment_tables"); + await pm.chartTypeSelector.selectStream(tableName); + await expect( + page.locator(`[data-test^="field-list-item-"][data-test$="-country"]`).first(), + ).toBeVisible(); + + await addCountYAxis(pm); + await pm.dashboardPanelActions.addPanelName("Picker"); + await pm.dashboardPanelActions.savePanelAndWaitForNavigation(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); +}); diff --git a/tests/ui-testing/playwright-tests/dashboards/utils/enrichmentTableHelpers.js b/tests/ui-testing/playwright-tests/dashboards/utils/enrichmentTableHelpers.js new file mode 100644 index 0000000000..0c8e937d2f --- /dev/null +++ b/tests/ui-testing/playwright-tests/dashboards/utils/enrichmentTableHelpers.js @@ -0,0 +1,176 @@ +// Copyright 2026 OpenObserve Inc. + +import { expect } from "@playwright/test"; +import { setupTestDashboard } from "./dashCreation.js"; + +// INGESTION_URL first: when the UI runs on its own dev server only that host serves the API. +const BASE = (process.env.INGESTION_URL || process.env.ZO_BASE_URL || "").replace(/\/$/, ""); +const STREAM_TYPE = "enrichment_tables"; +const WEEK_MS = 7 * 24 * 3600 * 1000; + +const orgId = () => process.env["ORGNAME"]; + +const authHeader = () => + `Basic ${Buffer.from( + `${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}`, + ).toString("base64")}`; + +export const generateDashboardName = () => + `Dashboard_Enrich_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + +export async function uploadEnrichmentTable(page, name, csv) { + const upload = () => + page.request.post(`${BASE}/api/${orgId()}/enrichment_tables/${name}?append=false`, { + headers: { Authorization: authHeader() }, + multipart: { file: { name: `${name}.csv`, mimeType: "text/csv", buffer: Buffer.from(csv) } }, + }); + let res = await upload(); + for (let attempt = 1; attempt <= 4 && res.status() === 503; attempt++) { + await page.waitForTimeout(1500 * attempt); + res = await upload(); + } + expect(res.status(), `enrichment table ${name} must upload`).toBe(200); +} + +export async function waitForRowCount(page, name, rowCount) { + const now = Date.now() * 1000; + await expect + .poll( + async () => { + const res = await page.request.post(`${BASE}/api/${orgId()}/_search?type=${STREAM_TYPE}`, { + headers: { Authorization: authHeader(), "Content-Type": "application/json" }, + data: { + query: { + sql: `SELECT count(*) AS c FROM "${name}"`, + start_time: now - 24 * 3600 * 1_000_000, + end_time: now + 3600 * 1_000_000, + from: 0, + size: 1, + }, + }, + }); + if (res.status() !== 200) return -1; + return (await res.json())?.hits?.[0]?.c ?? -1; + }, + { timeout: 90000, intervals: [1000, 2000, 3000] }, + ) + .toBe(rowCount); +} + +export async function deleteEnrichmentTable(page, name) { + await page.request + .delete(`${BASE}/api/${orgId()}/streams/${name}?type=${STREAM_TYPE}`, { + headers: { Authorization: authHeader() }, + }) + .catch(() => {}); +} + +export async function waitForTablePanelWithData(page, timeout = 30000) { + await page.locator('[data-test="dashboard-panel-table"]').first().waitFor({ state: "visible", timeout }); + await page.waitForFunction( + () => + Array.from(document.querySelectorAll('[data-test="dashboard-panel-table"] tbody td')).some( + (cell) => cell.offsetParent !== null && cell.textContent.trim().length > 0, + ), + null, + { timeout }, + ); +} + +// Cells carry a copy button whose icon ligature would leak into textContent. +async function firstColumn(page) { + const cells = await page.evaluate(() => + Array.from(document.querySelectorAll('[data-test="dashboard-panel-table"] tbody tr')).map((row) => { + const cell = row.querySelector("td")?.cloneNode(true); + cell?.querySelectorAll(".q-btn").forEach((btn) => btn.remove()); + return (cell?.textContent ?? "").trim(); + }), + ); + return cells.filter(Boolean).sort(); +} + +export async function expectFirstColumn(page, values) { + await expect.poll(() => firstColumn(page), { timeout: 30000 }).toEqual([...values].sort()); +} + +// The picker shows last week, so requests ending now prove the panel searched the table's own span. +export async function openOnLastWeek(page, tableName) { + const from = Date.now() - 2 * WEEK_MS; + const to = Date.now() - WEEK_MS; + const url = new URL(page.url()); + url.searchParams.delete("period"); + url.searchParams.set("from", String(from)); + url.searchParams.set("to", String(to)); + const panelRequests = []; + const onRequest = (req) => { + if (req.url().includes("/_search_stream") && (req.postData() || "").includes(tableName)) panelRequests.push(req); + }; + page.on("request", onRequest); + await page.goto(url.toString()); + const refreshBtn = page.locator('[data-test="dashboard-refresh-btn"]'); + await refreshBtn.waitFor({ state: "visible", timeout: 30000 }); + await page + .locator('[data-test="dashboard-panel-table"], [data-test="chart-renderer"]') + .first() + .waitFor({ state: "visible", timeout: 30000 }); + // A reload can paint the panel from its cache without a query, so refresh to force one. + if (panelRequests.length === 0) await refreshBtn.click(); + await expect.poll(() => panelRequests.length, { timeout: 30000 }).toBeGreaterThan(0); + page.off("request", onRequest); + for (const req of panelRequests) { + expect(req.postDataJSON().query.end_time).toBeGreaterThan((Date.now() - 5 * 60000) * 1000); + } +} + +export async function expectPlottedCount(pm, count) { + const plotted = async () => + (await pm.dashboardPanelActions.getChartSeries()) + .flatMap((series) => series.values) + .filter((value) => value !== null && value !== undefined && value !== "-") + .reduce((sum, value) => sum + Number(value), 0); + await expect.poll(plotted, { timeout: 30000 }).toBe(count); +} + +// v0.40 keeps the builder's histogram x item in custom mode and maps no axes, so both are set by hand. +export async function setCustomSQL(page, pm, query) { + await pm.chartTypeSelector.removeField("x_axis_1", "x"); + await page.locator('[data-test="dashboard-custom-query-type"]').click(); + await pm.chartTypeSelector.enterCustomSQL(query); + await page.keyboard.press("Escape"); + await pm.chartTypeSelector.searchAndAddField("x_axis_1", "x"); + await pm.chartTypeSelector.searchAndAddField("y_axis_1", "y"); +} + +// A result_schema call that resolves the table as a logs stream answers 400. +export function trackResultSchemaRequests(page) { + const requests = []; + page.on("request", (req) => { + if (req.url().includes("/result_schema")) requests.push(req); + }); + return async () => { + const failed = []; + for (const req of requests) { + const res = await req.response(); + if (res && res.status() >= 400 && res.status() < 500) failed.push(`${res.status()} ${req.url()}`); + } + expect(failed, "no result_schema request may fail").toEqual([]); + }; +} + +export async function waitForCustomQueryAxes(page, xAlias, yAlias) { + await page.locator(`[data-test="dashboard-x-item-${xAlias}"]`).first().waitFor({ state: "attached", timeout: 15000 }); + await page.locator(`[data-test="dashboard-y-item-${yAlias}"]`).first().waitFor({ state: "attached", timeout: 15000 }); +} + +// v0.40 seeds only the histogram x-axis, so add the count(_timestamp) y-axis that later releases seed by default. +export async function addCountYAxis(pm) { + await pm.chartTypeSelector.searchAndAddField("_timestamp", "y"); +} + +export async function addEnrichmentPanel(page, pm, dashboardName, tableName, chartType) { + await setupTestDashboard(page, pm, dashboardName); + await pm.dashboardCreate.addPanel(); + await pm.chartTypeSelector.selectChartType(chartType); + await pm.chartTypeSelector.selectStreamType(STREAM_TYPE); + await pm.chartTypeSelector.selectStream(tableName); +} diff --git a/web/src/components/dashboards/PanelContainer.spec.ts b/web/src/components/dashboards/PanelContainer.spec.ts index 29f7bfeb6d..04e1c82c01 100644 --- a/web/src/components/dashboards/PanelContainer.spec.ts +++ b/web/src/components/dashboards/PanelContainer.spec.ts @@ -458,6 +458,51 @@ describe("PanelContainer", () => { }); }); + describe("Create Alert From Panel", () => { + const panelOn = (streamType: string) => ({ + ...mockPanelData, + queries: [ + { + query: "SELECT * FROM geo", + queryType: "sql", + fields: { stream: "geo", stream_type: streamType }, + }, + ], + }); + + // The component's $router is the mounting mock, which wrapper.vm.$router does not expose. + const spyOnRouterPush = () => vi.spyOn(wrapper.vm.$.proxy.$router, "push"); + + it("refuses an enrichment-table panel with a notice instead of opening the alert form", () => { + wrapper = createWrapper({ data: panelOn("enrichment_tables") }); + const notifySpy = vi.spyOn(wrapper.vm.$q, "notify"); + const pushSpy = spyOnRouterPush(); + + wrapper.vm.createAlertFromPanel(); + + expect(notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "negative", + message: i18n.global.t("panel.enrichmentTableAlertUnsupported"), + }), + ); + expect(pushSpy).not.toHaveBeenCalled(); + }); + + it("opens the alert form for a logs panel", () => { + wrapper = createWrapper({ data: panelOn("logs") }); + const notifySpy = vi.spyOn(wrapper.vm.$q, "notify"); + const pushSpy = spyOnRouterPush(); + + wrapper.vm.createAlertFromPanel(); + + expect(notifySpy).not.toHaveBeenCalled(); + expect(pushSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: "addAlert" }), + ); + }); + }); + describe("Performance", () => { it("should handle component updates without errors", async () => { wrapper = createWrapper(); diff --git a/web/src/components/dashboards/PanelContainer.vue b/web/src/components/dashboards/PanelContainer.vue index 7d629acbcd..afdd64c7e8 100644 --- a/web/src/components/dashboards/PanelContainer.vue +++ b/web/src/components/dashboards/PanelContainer.vue @@ -460,6 +460,7 @@ import useNotifications from "@/composables/useNotifications"; import { isEqual } from "lodash-es"; import { b64EncodeUnicode } from "@/utils/zincutils"; import shortURL from "@/services/short_url"; +import { isEnrichmentTablePanel } from "@/utils/dashboard/streamLifetime"; import config from "@/aws-exports"; import { useI18n } from "vue-i18n"; @@ -1009,6 +1010,16 @@ export default defineComponent({ return; } + // The alert form offers no enrichment-table stream type, so the prefill would be rejected. + if (isEnrichmentTablePanel(this.props.data)) { + this.$q.notify({ + type: "negative", + message: this.t("panel.enrichmentTableAlertUnsupported"), + timeout: 2000, + }); + return; + } + const unsupportedTypes = ["markdown", "html", "geomap", "sankey"]; if (unsupportedTypes.includes(this.props.data.type)) { this.$q.notify({ diff --git a/web/src/components/dashboards/PanelSchemaRenderer.spec.ts b/web/src/components/dashboards/PanelSchemaRenderer.spec.ts index f1a00127ac..aaec11f0fd 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.spec.ts +++ b/web/src/components/dashboards/PanelSchemaRenderer.spec.ts @@ -154,6 +154,21 @@ vi.mock("@/components/dashboards/addPanel/AddAnnotation.vue", () => ({ }, })); +const { mockGetStream } = vi.hoisted(() => ({ + mockGetStream: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/composables/useStreams", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + default: (...args: Parameters) => ({ + ...actual.default(...args), + getStream: mockGetStream, + }), + }; +}); + vi.mock("@/components/common/LoadingProgress.vue", () => ({ default: { name: "LoadingProgress", @@ -172,6 +187,7 @@ global.console = { import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue"; import i18n from "@/locales"; import store from "@/test/unit/helpers/store"; +import searchService from "@/services/search"; installQuasar(); @@ -1597,6 +1613,86 @@ describe("PanelSchemaRenderer", () => { }); }); + describe("Drilldown to logs time range", () => { + const drillIntoLogs = async (fields: { + stream: string; + stream_type: string; + }) => { + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + wrapper = createWrapper({ + panelSchema: { + ...defaultProps.panelSchema, + type: "pie", + queries: [ + { + ...defaultProps.panelSchema.queries[0], + fields: { + ...defaultProps.panelSchema.queries[0].fields, + ...fields, + }, + }, + ], + config: { + drilldown: [ + { + name: "Logs", + type: "logs", + targetBlank: true, + data: { logsMode: "custom", logsQuery: 'SELECT * FROM "geo"' }, + }, + ], + }, + }, + }); + wrapper.vm.metadata.value = { + queries: [{ query: 'SELECT * FROM "geo"' }], + }; + + await wrapper.vm.onChartClick({ name: "a", value: 5, seriesName: "a" }); + await wrapper.vm.openDrilldown(0); + await flushPromises(); + + expect(openSpy).toHaveBeenCalledTimes(1); + const logsUrl = new URL(openSpy.mock.calls[0][0] as string); + openSpy.mockRestore(); + return { + from: Number(logsUrl.searchParams.get("from")), + to: Number(logsUrl.searchParams.get("to")), + }; + }; + + it("opens logs on the enrichment table's span", async () => { + const tableStartUs = Date.UTC(2020, 0, 1) * 1000; + mockGetStream.mockResolvedValueOnce({ + stats: { doc_time_min: tableStartUs, doc_time_max: tableStartUs }, + }); + + const { from, to } = await drillIntoLogs({ + stream: "geo", + stream_type: "enrichment_tables", + }); + + expect(mockGetStream).toHaveBeenCalledWith( + "geo", + "enrichment_tables", + false, + ); + expect(from).toBe(tableStartUs - 1_000_000); + expect(to).toBeGreaterThanOrEqual((Date.now() - 60_000) * 1000); + }); + + it("opens logs on the dashboard range for a logs panel", async () => { + const { from, to } = await drillIntoLogs({ + stream: "geo", + stream_type: "logs", + }); + + expect(mockGetStream).not.toHaveBeenCalled(); + expect(from).toBe(defaultProps.selectedTimeObj.start_time.getTime()); + expect(to).toBe(defaultProps.selectedTimeObj.end_time.getTime()); + }); + }); + describe("SQL Parser Integration", () => { it("should support SQL parser functionality", () => { wrapper = createWrapper(); @@ -2256,5 +2352,54 @@ describe("PanelSchemaRenderer", () => { expect(wrapper.vm.panelSchema.queries).toBeDefined(); expect(wrapper.vm.panelSchema.queries.length).toBeGreaterThan(0); }); + + describe("result_schema request for cross-links", () => { + let resultSchemaSpy: ReturnType; + let originalCrossLinking: boolean | undefined; + + beforeEach(() => { + originalCrossLinking = store.state.zoConfig.enable_cross_linking; + store.state.zoConfig.enable_cross_linking = true; + resultSchemaSpy = vi + .spyOn(searchService, "result_schema") + .mockResolvedValue({ data: { cross_links: undefined } }); + }); + + afterEach(() => { + store.state.zoConfig.enable_cross_linking = originalCrossLinking; + resultSchemaSpy.mockRestore(); + }); + + it("should not request result_schema for an enrichment table panel", async () => { + wrapper = createWrapper({ + panelSchema: { + ...defaultProps.panelSchema, + queries: [ + { + ...defaultProps.panelSchema.queries[0], + fields: { + ...defaultProps.panelSchema.queries[0].fields, + stream: "geo_ip", + stream_type: "enrichment_tables", + }, + }, + ], + }, + }); + await flushPromises(); + + expect(resultSchemaSpy).not.toHaveBeenCalled(); + }); + + it("should request result_schema for a logs panel", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(resultSchemaSpy).toHaveBeenCalledWith( + expect.objectContaining({ cross_linking: true }), + "dashboards", + ); + }); + }); }); }); \ No newline at end of file diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index d298cb7ed5..a78365dd3a 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -350,6 +350,8 @@ import { validateSQLPanelFields, } from "@/utils/dashboard/convertDataIntoUnitValue"; import { useAnnotationsData } from "@/composables/dashboard/useAnnotationsData"; +import useStreams from "@/composables/useStreams"; +import { getEnrichmentTableRange } from "@/utils/dashboard/streamLifetime"; import { event } from "quasar"; import searchService from "@/services/search"; import { exportFile } from "quasar"; @@ -526,6 +528,7 @@ export default defineComponent({ const store = useStore(); const route = useRoute(); const router = useRouter(); + const { getStream } = useStreams(); // ============================================================================ // Hidden Queries Feature Setup @@ -1046,7 +1049,10 @@ export default defineComponent({ watch( () => metadata.value?.queries?.[0]?.query || panelSchema.value?.queries?.[0]?.query, async (newQuery: string) => { - if (!store.state.zoConfig?.enable_cross_linking || !newQuery || panelSchema.value?.queryType === "promql") { + // Cross-linking resolves the stream as logs, so other stream types would only get a failed request. + const crossLinkStreamType = + panelSchema.value?.queries?.[0]?.fields?.stream_type ?? "logs"; + if (!store.state.zoConfig?.enable_cross_linking || crossLinkStreamType !== "logs" || !newQuery || panelSchema.value?.queryType === "promql") { crossLinksData.value = { stream_links: [], org_links: [] }; return; } @@ -1586,7 +1592,7 @@ export default defineComponent({ return { originalQuery, streamName }; }; - const calculateTimeRange = ( + const calculateTimeRange = async ( hoveredTimestamp: number | null, interval: number | undefined, ) => { @@ -1597,6 +1603,13 @@ export default defineComponent({ endTime: startTime + interval, }; } + // The panel read the whole enrichment table, so the logs page opens on that same span. + const tableRange = await getEnrichmentTableRange( + getStream, + panelSchema.value, + ); + if (tableRange) + return { startTime: tableRange.from, endTime: tableRange.to }; return { startTime: selectedTimeObj.value.start_time.getTime(), endTime: selectedTimeObj.value.end_time.getTime(), @@ -1961,7 +1974,7 @@ export default defineComponent({ : null; const breakdown = queryDetails.queries[0].fields?.breakdown || []; - const calculatedTimeRange = calculateTimeRange( + const calculatedTimeRange = await calculateTimeRange( hoveredTimestamp, intervalMicro.value, ); diff --git a/web/src/components/dashboards/addPanel/FieldList.spec.ts b/web/src/components/dashboards/addPanel/FieldList.spec.ts index 932976164e..29e8792cbc 100644 --- a/web/src/components/dashboards/addPanel/FieldList.spec.ts +++ b/web/src/components/dashboards/addPanel/FieldList.spec.ts @@ -14,7 +14,7 @@ // along with this program. If not, see . import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; -import { mount, flushPromises } from "@vue/test-utils"; +import { mount, flushPromises, VueWrapper } from "@vue/test-utils"; import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; import { Dialog, Notify } from "quasar"; @@ -201,6 +201,36 @@ describe("FieldList", () => { expect(streamTypeDropdown.exists()).toBe(true); } }); + + it("offers enrichment tables on the dashboard page", () => { + wrapper = createWrapper({}, { pageKey: "dashboard" }); + + const streamTypeDropdown = wrapper + .findAllComponents({ name: "QSelect" }) + .find( + (select: VueWrapper) => + select.vm.$attrs["data-test"] === "index-dropdown-stream_type", + ); + expect(streamTypeDropdown?.props("options")).toEqual([ + "logs", + "metrics", + "traces", + "enrichment_tables", + ]); + }); + + it.each(["logs", "metrics", "traces"])( + "does not offer enrichment tables on the %s page", + (pageKey) => { + wrapper = createWrapper({}, { pageKey }); + + expect(wrapper.vm.data.streamType).toEqual([ + "logs", + "metrics", + "traces", + ]); + }, + ); }); describe("Stream Selection", () => { diff --git a/web/src/components/dashboards/addPanel/FieldList.vue b/web/src/components/dashboards/addPanel/FieldList.vue index b7c352e570..e69c7e70ad 100644 --- a/web/src/components/dashboards/addPanel/FieldList.vue +++ b/web/src/components/dashboards/addPanel/FieldList.vue @@ -592,7 +592,15 @@ export default defineComponent({ const data = reactive({ // schemaList: [], // indexOptions: [], - streamType: ["logs", "metrics", "traces"], + // Only a dashboard panel searches an enrichment table over its whole lifetime, so only it offers one. + streamType: [ + "logs", + "metrics", + "traces", + ...(dashboardPanelDataPageKey === "dashboard" + ? ["enrichment_tables"] + : []), + ], }); const filteredStreams = ref([]); const { diff --git a/web/src/composables/dashboard/usePanelDataLoader.spec.ts b/web/src/composables/dashboard/usePanelDataLoader.spec.ts index 22ac918178..199e2b3955 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.spec.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.spec.ts @@ -36,11 +36,14 @@ vi.mock("../../services/search", () => ({ }, })); +const mockZoConfig: { sql_base64_enabled: boolean; timestamp_column?: string } = + vi.hoisted(() => ({ sql_base64_enabled: false })); + vi.mock("vuex", () => ({ useStore: () => ({ state: { selectedOrganization: { identifier: "test-org" }, - zoConfig: { sql_base64_enabled: false }, + zoConfig: mockZoConfig, organizationData: { organizationSettings: { scrape_interval: 15 }, }, @@ -154,12 +157,21 @@ vi.mock("./useAnnotations", () => ({ // Enhanced HTTP Streaming mocking let mockStreamingCallbacks: any = {}; let shouldStreamingThrow = false; +const streamPayloads: { + queryReq: { query: { start_time: number; end_time: number } }; +}[] = []; + +const { mockGetStream } = vi.hoisted(() => ({ mockGetStream: vi.fn() })); +vi.mock("@/composables/useStreams", () => ({ + default: () => ({ getStream: mockGetStream }), +})); vi.mock("../useStreamingSearch", () => ({ default: () => ({ fetchQueryDataWithHttpStream: vi .fn() .mockImplementation((payload, callbacks) => { + streamPayloads.push(payload); mockStreamingCallbacks = callbacks; if (shouldStreamingThrow) { callbacks?.error?.(payload, { message: "Streaming error" }); @@ -258,6 +270,7 @@ const resetAllMocks = () => { // Reset streaming mocks mockStreamingCallbacks = {}; shouldStreamingThrow = false; + streamPayloads.length = 0; // Reset cache mocks mockCacheData = null; @@ -2833,6 +2846,118 @@ describe("usePanelDataLoader", () => { }); // ==== COMPREHENSIVE FINAL TESTS FOR 100% COVERAGE ==== + describe("enrichment table panels", () => { + // Without a timestamp column the SQL path throws before the search is sent. + beforeEach(() => { + mockZoConfig.timestamp_column = "_timestamp"; + }); + + afterEach(() => { + delete mockZoConfig.timestamp_column; + }); + + const RealAbortController = globalThis.AbortController; + + const createPanelLoader = (fields: { + stream: string; + stream_type: string; + }) => { + const selectedTimeObj = createMockSelectedTimeObj({ + start_time: new Date(Date.now() - 3600000), + end_time: new Date(), + }); + const loader = usePanelDataLoader( + createMockPanelSchema({ queryConfig: { fields } }), + selectedTimeObj, + 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), + ); + return { loader, selectedTimeObj }; + }; + + const loadPanel = async (fields: { + stream: string; + stream_type: string; + }) => { + const { loader, selectedTimeObj } = createPanelLoader(fields); + await loader.loadData(); + return selectedTimeObj.value; + }; + + it("searches the table's own span instead of the dashboard range", async () => { + const tableStartUs = Date.UTC(2020, 0, 1) * 1000; + mockGetStream.mockResolvedValueOnce({ + stats: { doc_time_min: tableStartUs, doc_time_max: tableStartUs }, + }); + + await loadPanel({ stream: "geo", stream_type: "enrichment_tables" }); + + expect(mockGetStream).toHaveBeenCalledWith( + "geo", + "enrichment_tables", + false, + ); + expect(streamPayloads).toHaveLength(1); + const { start_time, end_time } = streamPayloads[0].queryReq.query; + expect(start_time).toBe(tableStartUs - 1_000_000); + expect(end_time).toBeGreaterThanOrEqual((Date.now() - 60_000) * 1000); + }); + + it("lets only the newest load search when two wait on the table lookup", async () => { + const tableStartUs = Date.UTC(2020, 0, 1) * 1000; + const stream = { + stats: { doc_time_min: tableStartUs, doc_time_max: tableStartUs }, + }; + const releases: ((value: typeof stream) => void)[] = []; + mockGetStream.mockImplementation( + () => new Promise((resolve) => releases.push(resolve)), + ); + // The shared mock never flips signal.aborted, which is what supersedes a run. + globalThis.AbortController = RealAbortController; + const { loader } = createPanelLoader({ + stream: "geo", + stream_type: "enrichment_tables", + }); + + const staleLoad = loader.loadData(); + await vi.waitFor(() => expect(releases).toHaveLength(1)); + const newestLoad = loader.loadData(); + await vi.waitFor(() => expect(releases).toHaveLength(2)); + + releases[0](stream); + await staleLoad; + expect(streamPayloads).toHaveLength(0); + + releases[1](stream); + await newestLoad; + expect(streamPayloads).toHaveLength(1); + }); + + it("keeps the dashboard range for a logs panel", async () => { + const { start_time, end_time } = await loadPanel({ + stream: "app", + stream_type: "logs", + }); + + expect(mockGetStream).not.toHaveBeenCalled(); + expect(streamPayloads).toHaveLength(1); + expect(streamPayloads[0].queryReq.query).toMatchObject({ + start_time: start_time.getTime(), + end_time: end_time.getTime(), + }); + }); + }); + describe("Complete Coverage Tests", () => { it("should achieve 100% line coverage with all scenarios", async () => { // Test all remaining scenarios for complete coverage diff --git a/web/src/composables/dashboard/usePanelDataLoader.ts b/web/src/composables/dashboard/usePanelDataLoader.ts index 3204435f5b..7d74a879b7 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.ts @@ -47,6 +47,8 @@ import useHttpStreamingSearch from "../useStreamingSearch"; import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall"; import logsUtils from "@/composables/useLogs/logsUtils"; import { createPromQLChunkProcessor } from "./promqlChunkProcessor"; +import useStreams from "@/composables/useStreams"; +import { getEnrichmentTableRange } from "@/utils/dashboard/streamLifetime"; /** * debounce time in milliseconds for panel data loader @@ -88,6 +90,7 @@ export const usePanelDataLoader = ( let runCount = 0; const store = useStore(); + const { getStream } = useStreams(); const getRegionClusterParams = () => { if ( @@ -867,6 +870,19 @@ export const usePanelDataLoader = ( return; } + // Enrichment rows carry their upload time, so the dashboard range would hide them; Explore does the same. + const runSignal = abortController.signal; + const tableRange = await getEnrichmentTableRange( + getStream, + panelSchema.value, + ); + // A newer run started during the lookup; resuming would abort it after it cleared the panel's data. + if (runSignal.aborted) return; + if (tableRange) { + startISOTimestamp = tableRange.from; + endISOTimestamp = tableRange.to; + } + log( "loadData: panelcache: no cache restored, continue firing, runCount ", runCount, diff --git a/web/src/composables/useStreamingSearch.spec.ts b/web/src/composables/useStreamingSearch.spec.ts index 0813eb6333..412ce0c4fb 100644 --- a/web/src/composables/useStreamingSearch.spec.ts +++ b/web/src/composables/useStreamingSearch.spec.ts @@ -197,6 +197,28 @@ let onDataSpy: any; expect(mockHandlers.data).toHaveBeenCalledTimes(1); }); + it("should bypass the result cache for an enrichment table search", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + body: new MockReadableStream() as any, + }); + + await httpStreaming.fetchQueryDataWithHttpStream( + { + ...mockData, + traceId: "enrichment-trace", + pageType: "enrichment_tables", + }, + mockHandlers, + ); + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledWith( + `${store.state.API_ENDPOINT}/api/test-org/_search_stream?type=enrichment_tables&search_type=ui&use_cache=false`, + expect.anything(), + ); + }); + it("should throw an error when the stream connection fails", async () => { const onErrorSpy = vi.spyOn(httpStreaming, "onError"); diff --git a/web/src/composables/useStreamingSearch.ts b/web/src/composables/useStreamingSearch.ts index b9eaa19ce2..250b9af58c 100644 --- a/web/src/composables/useStreamingSearch.ts +++ b/web/src/composables/useStreamingSearch.ts @@ -186,9 +186,13 @@ const useHttpStreaming = () => { // Construct URL based on search type let url = ''; - const use_cache = (window as any).use_cache !== undefined - ? (window as any).use_cache - : true; + // An enrichment table ignores the time range, so a cached result plus its "new" window repeats every row. + const use_cache = + pageType === "enrichment_tables" + ? false + : (window as any).use_cache !== undefined + ? (window as any).use_cache + : true; // Check if this is a multi-stream request (similar to search.ts logic) const isMultiStream = typeof queryReq.query?.sql !== "string"; diff --git a/web/src/composables/useStreams.spec.ts b/web/src/composables/useStreams.spec.ts index 91edc50011..d182c1ab86 100644 --- a/web/src/composables/useStreams.spec.ts +++ b/web/src/composables/useStreams.spec.ts @@ -654,6 +654,22 @@ describe("useStreams Composable", () => { .rejects.toThrow("Service error"); }); + it("should settle getStream when the stream list it waits on fails", async () => { + vi.mocked(StreamService.nameList).mockRejectedValue( + new Error("List error"), + ); + + const listing = streamsInstance.getStreams("enrichment_tables", false); + const lookup = streamsInstance.getStream( + "geo", + "enrichment_tables", + false, + ); + + await expect(listing).rejects.toThrow("List error"); + await expect(lookup).rejects.toThrow("List error"); + }); + it("should handle StreamService.schema rejection in getStream", async () => { mockStore.state.streams.logs = { list: [{ name: "test", schema: [] }] }; mockStore.state.streams.streamsIndexMapping.logs["test"] = 0; diff --git a/web/src/composables/useStreams.ts b/web/src/composables/useStreams.ts index b493742a6c..a1b7687de0 100644 --- a/web/src/composables/useStreams.ts +++ b/web/src/composables/useStreams.ts @@ -253,7 +253,8 @@ const useStreams = () => { // Wait for the streams to be fetched if they are being fetched if (getStreamsPromise.value) { - await getStreamsPromise.value; + // A rejection here would be lost inside this async executor and leave the promise pending. + await getStreamsPromise.value.catch(() => {}); } // If the stream is not fetched, and trying to fetch the specific stream. First fetch all streams diff --git a/web/src/locales/languages/de.json b/web/src/locales/languages/de.json index a9295baf81..0a80dcb5c2 100644 --- a/web/src/locales/languages/de.json +++ b/web/src/locales/languages/de.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "Wählen Sie den Ziel-Tab", "noQueriesToCreateAlert": "Dieses Panel enthält keine Abfragen, von denen aus eine Warnung erstellt werden könnte", "panelQueryMustHaveStream": "Bei der Panel-Abfrage muss ein Stream konfiguriert sein", + "enrichmentTableAlertUnsupported": "Alarme können nicht für Anreicherungstabellen ausgeführt werden", "unsupportedPanelTypeAlert": "Das Erstellen von Benachrichtigungen aus {type} -Panels funktioniert möglicherweise nicht wie erwartet", "panelDuplicatedSuccessfully": "Panel wurde erfolgreich dupliziert", "panelDuplicationFailed": "Fehler beim Duplizieren des Panels", diff --git a/web/src/locales/languages/en.json b/web/src/locales/languages/en.json index b8cf819fa6..c1d0a8a2c3 100644 --- a/web/src/locales/languages/en.json +++ b/web/src/locales/languages/en.json @@ -1237,6 +1237,7 @@ "movePanelMessage": "Select destination tab", "noQueriesToCreateAlert": "This panel has no queries to create an alert from", "panelQueryMustHaveStream": "Panel query must have a stream configured", + "enrichmentTableAlertUnsupported": "Alerts can't run on enrichment tables", "unsupportedPanelTypeAlert": "Creating alerts from {type} panels may not work as expected", "panelDuplicatedSuccessfully": "Panel Duplicated Successfully", "panelDuplicationFailed": "Panel duplication failed", diff --git a/web/src/locales/languages/es.json b/web/src/locales/languages/es.json index 17531051f4..7edff3f5a4 100644 --- a/web/src/locales/languages/es.json +++ b/web/src/locales/languages/es.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "Seleccione la pestaña de destino", "noQueriesToCreateAlert": "Este panel no tiene consultas desde las que crear una alerta", "panelQueryMustHaveStream": "La consulta del panel debe tener una transmisión configurada", + "enrichmentTableAlertUnsupported": "Las alertas no se pueden ejecutar en tablas de enriquecimiento", "unsupportedPanelTypeAlert": "Es posible que la creación de alertas desde los paneles de {type} no funcione como se esperaba", "panelDuplicatedSuccessfully": "El panel se duplicó correctamente", "panelDuplicationFailed": "Falló la duplicación del panel", diff --git a/web/src/locales/languages/fr.json b/web/src/locales/languages/fr.json index 3970e3ea0a..4d3bb0b6f5 100644 --- a/web/src/locales/languages/fr.json +++ b/web/src/locales/languages/fr.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "Sélectionnez l'onglet de destination", "noQueriesToCreateAlert": "Ce panneau ne contient aucune requête à partir de laquelle créer une alerte", "panelQueryMustHaveStream": "La requête du panneau doit avoir un flux configuré", + "enrichmentTableAlertUnsupported": "Les alertes ne peuvent pas s'exécuter sur les tables d'enrichissement", "unsupportedPanelTypeAlert": "La création d'alertes à partir des panneaux {type} peut ne pas fonctionner comme prévu", "panelDuplicatedSuccessfully": "Panneau dupliqué avec succès", "panelDuplicationFailed": "La duplication du panneau a échoué", diff --git a/web/src/locales/languages/it.json b/web/src/locales/languages/it.json index fc3aac75a1..391d59cc39 100644 --- a/web/src/locales/languages/it.json +++ b/web/src/locales/languages/it.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "Seleziona la scheda di destinazione", "noQueriesToCreateAlert": "Questo pannello non ha domande da cui creare un avviso", "panelQueryMustHaveStream": "La query del pannello deve avere uno stream configurato", + "enrichmentTableAlertUnsupported": "Gli avvisi non possono essere eseguiti sulle tabelle di arricchimento", "unsupportedPanelTypeAlert": "La creazione di avvisi dai pannelli {type} potrebbe non funzionare come previsto", "panelDuplicatedSuccessfully": "Pannello duplicato con successo", "panelDuplicationFailed": "Duplicazione del pannello non riuscita", diff --git a/web/src/locales/languages/ja.json b/web/src/locales/languages/ja.json index f266b5680e..9d14fbe3ee 100644 --- a/web/src/locales/languages/ja.json +++ b/web/src/locales/languages/ja.json @@ -1149,6 +1149,7 @@ "movePanelMessage": "「送信先を選択」タブ", "noQueriesToCreateAlert": "このパネルには、アラートを作成するクエリはありません", "panelQueryMustHaveStream": "パネルクエリにはストリームが設定されている必要があります", + "enrichmentTableAlertUnsupported": "エンリッチメントテーブルではアラートを実行できません", "unsupportedPanelTypeAlert": "{type} パネルからアラートを作成すると、期待どおりに動作しない場合があります", "panelDuplicatedSuccessfully": "パネルは正常に複製されました", "panelDuplicationFailed": "パネルを複製できませんでした", diff --git a/web/src/locales/languages/ko.json b/web/src/locales/languages/ko.json index 6ea6425ee1..3a6eb2a936 100644 --- a/web/src/locales/languages/ko.json +++ b/web/src/locales/languages/ko.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "대상 선택 탭", "noQueriesToCreateAlert": "이 패널에는 알림을 생성할 쿼리가 없습니다.", "panelQueryMustHaveStream": "패널 쿼리에는 스트림이 구성되어 있어야 합니다.", + "enrichmentTableAlertUnsupported": "보강 테이블에서는 알림을 실행할 수 없습니다", "unsupportedPanelTypeAlert": "{type} 패널에서 알림 생성이 예상대로 작동하지 않을 수 있음", "panelDuplicatedSuccessfully": "패널 복제 성공", "panelDuplicationFailed": "패널 복제 실패", diff --git a/web/src/locales/languages/nl.json b/web/src/locales/languages/nl.json index ef94c786e8..51e1a8ab72 100644 --- a/web/src/locales/languages/nl.json +++ b/web/src/locales/languages/nl.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "Tabblad van bestemming selecteren", "noQueriesToCreateAlert": "Dit paneel bevat geen vragen om een waarschuwing te maken van", "panelQueryMustHaveStream": "Paneelquery moet een stream hebben geconfigureerd", + "enrichmentTableAlertUnsupported": "Waarschuwingen kunnen niet worden uitgevoerd op verrijkingstabellen", "unsupportedPanelTypeAlert": "Het aanmaken van meldingen vanuit {type} panels werkt mogelijk niet zoals verwacht", "panelDuplicatedSuccessfully": "Paneel met succes gedupliceerd", "panelDuplicationFailed": "Het dupliceren van het paneel is mislukt", diff --git a/web/src/locales/languages/pt.json b/web/src/locales/languages/pt.json index a5bf6b94a8..9ba19b3252 100644 --- a/web/src/locales/languages/pt.json +++ b/web/src/locales/languages/pt.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "Selecione a guia de destino", "noQueriesToCreateAlert": "Este painel não tem consultas para criar um alerta", "panelQueryMustHaveStream": "A consulta do painel deve ter um fluxo configurado", + "enrichmentTableAlertUnsupported": "Os alertas não podem ser executados em tabelas de enriquecimento", "unsupportedPanelTypeAlert": "A criação de alertas a partir de painéis {type} pode não funcionar conforme o esperado", "panelDuplicatedSuccessfully": "Painel duplicado com sucesso", "panelDuplicationFailed": "Falha na duplicação do painel", diff --git a/web/src/locales/languages/tr.json b/web/src/locales/languages/tr.json index 439a4560e5..498475202a 100644 --- a/web/src/locales/languages/tr.json +++ b/web/src/locales/languages/tr.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "Hedef sekmesini seçin", "noQueriesToCreateAlert": "Bu panelde bir uyarı oluşturmak için herhangi bir sorgu yok", "panelQueryMustHaveStream": "Panel sorgusunun yapılandırılmış bir akışı olmalıdır", + "enrichmentTableAlertUnsupported": "Uyarılar zenginleştirme tablolarında çalıştırılamaz", "unsupportedPanelTypeAlert": "{type} panellerinden uyarılar oluşturmak beklendiği gibi çalışmayabilir", "panelDuplicatedSuccessfully": "Panel Başarıyla Çoğaltıldı", "panelDuplicationFailed": "Panel çoğaltma başarısız oldu", diff --git a/web/src/locales/languages/zh.json b/web/src/locales/languages/zh.json index eeddcbad3f..9919df4f1f 100644 --- a/web/src/locales/languages/zh.json +++ b/web/src/locales/languages/zh.json @@ -1131,6 +1131,7 @@ "movePanelMessage": "选择目的地选项卡", "noQueriesToCreateAlert": "此面板没有可从中创建告警的查询", "panelQueryMustHaveStream": "面板查询必须配置数据流", + "enrichmentTableAlertUnsupported": "无法在富化表上运行告警", "unsupportedPanelTypeAlert": "从 {type} 面板创建告警可能无法按预期运行", "panelDuplicatedSuccessfully": "面板已成功复制", "panelDuplicationFailed": "面板复制失败", diff --git a/web/src/utils/dashboard/streamLifetime.spec.ts b/web/src/utils/dashboard/streamLifetime.spec.ts new file mode 100644 index 0000000000..91b27d1c5e --- /dev/null +++ b/web/src/utils/dashboard/streamLifetime.spec.ts @@ -0,0 +1,91 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getEnrichmentTableRange, + isEnrichmentTablePanel, +} from "./streamLifetime"; + +const panel = (stream_type: string, stream = "geo") => ({ + queries: [{ fields: { stream, stream_type } }], +}); + +describe("isEnrichmentTablePanel", () => { + it("reads the first query's stream type", () => { + expect(isEnrichmentTablePanel(panel("enrichment_tables"))).toBe(true); + expect(isEnrichmentTablePanel(panel("logs"))).toBe(false); + expect(isEnrichmentTablePanel(null)).toBe(false); + }); +}); + +describe("getEnrichmentTableRange", () => { + const now = Date.UTC(2026, 0, 2) * 1000; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("spans the table's rows and ends no earlier than now", async () => { + vi.spyOn(Date, "now").mockReturnValue(now / 1000); + const getStream = vi.fn().mockResolvedValue({ + stats: { doc_time_min: 5_000_000, doc_time_max: 9_000_000 }, + }); + expect( + await getEnrichmentTableRange(getStream, panel("enrichment_tables")), + ).toEqual({ + from: 4_000_000, + to: now, + }); + expect(getStream).toHaveBeenCalledWith("geo", "enrichment_tables", false); + }); + + it("ends a minute after rows stamped ahead of this clock", async () => { + vi.spyOn(Date, "now").mockReturnValue(now / 1000); + const getStream = vi.fn().mockResolvedValue({ + stats: { doc_time_min: now, doc_time_max: now + 5_000_000 }, + }); + expect( + (await getEnrichmentTableRange(getStream, panel("enrichment_tables"))) + ?.to, + ).toBe(now + 65_000_000); + }); + + it("falls back to the creation time before stats arrive", async () => { + vi.spyOn(Date, "now").mockReturnValue(now / 1000); + const getStream = vi + .fn() + .mockResolvedValue({ stats: { created_at: 100_000_000 } }); + expect( + await getEnrichmentTableRange(getStream, panel("enrichment_tables")), + ).toEqual({ + from: 40_000_000, + to: now, + }); + }); + + it("leaves other streams and unknown tables to the dashboard range", async () => { + const getStream = vi.fn().mockResolvedValue(null); + expect(await getEnrichmentTableRange(getStream, panel("logs"))).toBeNull(); + expect(getStream).not.toHaveBeenCalled(); + expect( + await getEnrichmentTableRange(getStream, panel("enrichment_tables")), + ).toBeNull(); + getStream.mockRejectedValueOnce(new Error("offline")); + expect( + await getEnrichmentTableRange(getStream, panel("enrichment_tables")), + ).toBeNull(); + }); +}); diff --git a/web/src/utils/dashboard/streamLifetime.ts b/web/src/utils/dashboard/streamLifetime.ts new file mode 100644 index 0000000000..8d4527e9ed --- /dev/null +++ b/web/src/utils/dashboard/streamLifetime.ts @@ -0,0 +1,53 @@ +// 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 . + +interface PanelStreamType { + queries?: { fields?: { stream?: string; stream_type?: string } }[]; +} + +interface StreamTimeStats { + stats?: { doc_time_min?: number; doc_time_max?: number; created_at?: number }; +} + +/** True when the panel reads an enrichment table, whose rows carry their upload time. */ +export const isEnrichmentTablePanel = ( + panelSchema: PanelStreamType | null | undefined, +): boolean => + panelSchema?.queries?.[0]?.fields?.stream_type === "enrichment_tables"; + +/** The whole table's span in microseconds, padded as Explore does and ending no earlier than now. */ +export const getEnrichmentTableRange = async ( + getStream: ( + name: string, + type: string, + schema: boolean, + ) => Promise, + panelSchema: PanelStreamType | null | undefined, +): Promise<{ from: number; to: number } | null> => { + const stream = panelSchema?.queries?.[0]?.fields?.stream; + if (!stream || !isEnrichmentTablePanel(panelSchema)) return null; + const stats = ( + await getStream(stream, "enrichment_tables", false).catch(() => null) + )?.stats; + const start = stats?.doc_time_min + ? stats.doc_time_min - 1_000_000 + : (stats?.created_at ?? 0) - 60_000_000; + if (start <= 0) return null; + const end = Math.max( + (stats?.doc_time_max ?? 0) + 60_000_000, + Date.now() * 1000, + ); + return { from: start, to: end }; +};