mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
feat(dashboards): build panels on enrichment table streams [v0.40] (#16225)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+177
@@ -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");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1149,6 +1149,7 @@
|
||||
"movePanelMessage": "「送信先を選択」タブ",
|
||||
"noQueriesToCreateAlert": "このパネルには、アラートを作成するクエリはありません",
|
||||
"panelQueryMustHaveStream": "パネルクエリにはストリームが設定されている必要があります",
|
||||
"enrichmentTableAlertUnsupported": "エンリッチメントテーブルではアラートを実行できません",
|
||||
"unsupportedPanelTypeAlert": "{type} パネルからアラートを作成すると、期待どおりに動作しない場合があります",
|
||||
"panelDuplicatedSuccessfully": "パネルは正常に複製されました",
|
||||
"panelDuplicationFailed": "パネルを複製できませんでした",
|
||||
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"movePanelMessage": "대상 선택 탭",
|
||||
"noQueriesToCreateAlert": "이 패널에는 알림을 생성할 쿼리가 없습니다.",
|
||||
"panelQueryMustHaveStream": "패널 쿼리에는 스트림이 구성되어 있어야 합니다.",
|
||||
"enrichmentTableAlertUnsupported": "보강 테이블에서는 알림을 실행할 수 없습니다",
|
||||
"unsupportedPanelTypeAlert": "{type} 패널에서 알림 생성이 예상대로 작동하지 않을 수 있음",
|
||||
"panelDuplicatedSuccessfully": "패널 복제 성공",
|
||||
"panelDuplicationFailed": "패널 복제 실패",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user