feat(dashboards): build panels on enrichment table streams [v0.40] (#16225)

This commit is contained in:
Abhay Padamani
2026-11-03 09:52:06 +00:00
committed by GitHub
parent 5964310fbf
commit cd27da87a9
29 changed files with 986 additions and 10 deletions
+1
View File
@@ -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"
@@ -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);
}
}
@@ -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);
},
);
});
@@ -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);
}
@@ -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();
@@ -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({
@@ -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<typeof import("@/composables/useStreams")>();
return {
...actual,
default: (...args: Parameters<typeof actual.default>) => ({
...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<typeof vi.spyOn>;
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",
);
});
});
});
});
@@ -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,
);
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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", () => {
@@ -592,7 +592,15 @@ export default defineComponent({
const data = reactive<any>({
// 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 {
@@ -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
@@ -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,
@@ -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");
+7 -3
View File
@@ -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";
+16
View File
@@ -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;
+2 -1
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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é",
+1
View File
@@ -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",
+1
View File
@@ -1149,6 +1149,7 @@
"movePanelMessage": "「送信先を選択」タブ",
"noQueriesToCreateAlert": "このパネルには、アラートを作成するクエリはありません",
"panelQueryMustHaveStream": "パネルクエリにはストリームが設定されている必要があります",
"enrichmentTableAlertUnsupported": "エンリッチメントテーブルではアラートを実行できません",
"unsupportedPanelTypeAlert": "{type} パネルからアラートを作成すると、期待どおりに動作しない場合があります",
"panelDuplicatedSuccessfully": "パネルは正常に複製されました",
"panelDuplicationFailed": "パネルを複製できませんでした",
+1
View File
@@ -1131,6 +1131,7 @@
"movePanelMessage": "대상 선택 탭",
"noQueriesToCreateAlert": "이 패널에는 알림을 생성할 쿼리가 없습니다.",
"panelQueryMustHaveStream": "패널 쿼리에는 스트림이 구성되어 있어야 합니다.",
"enrichmentTableAlertUnsupported": "보강 테이블에서는 알림을 실행할 수 없습니다",
"unsupportedPanelTypeAlert": "{type} 패널에서 알림 생성이 예상대로 작동하지 않을 수 있음",
"panelDuplicatedSuccessfully": "패널 복제 성공",
"panelDuplicationFailed": "패널 복제 실패",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1131,6 +1131,7 @@
"movePanelMessage": "选择目的地选项卡",
"noQueriesToCreateAlert": "此面板没有可从中创建告警的查询",
"panelQueryMustHaveStream": "面板查询必须配置数据流",
"enrichmentTableAlertUnsupported": "无法在富化表上运行告警",
"unsupportedPanelTypeAlert": "从 {type} 面板创建告警可能无法按预期运行",
"panelDuplicatedSuccessfully": "面板已成功复制",
"panelDuplicationFailed": "面板复制失败",
@@ -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 <http://www.gnu.org/licenses/>.
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();
});
});
+53
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<StreamTimeStats | null>,
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 };
};