From 4326bc220da09382cb776dcfb8c2cc96d460344b Mon Sep 17 00:00:00 2001 From: ktx-akshay Date: Mon, 2 Nov 2026 13:05:48 +0000 Subject: [PATCH 1/9] fix(dashboards): show one No Data state on an empty logs panel (#16227) Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> --- .../components/dashboards/panels/LogsPanelRenderer.spec.ts | 6 ++++++ web/src/components/dashboards/panels/LogsPanelRenderer.vue | 2 ++ 2 files changed, 8 insertions(+) diff --git a/web/src/components/dashboards/panels/LogsPanelRenderer.spec.ts b/web/src/components/dashboards/panels/LogsPanelRenderer.spec.ts index ef4b6fa886..6455b55d62 100644 --- a/web/src/components/dashboards/panels/LogsPanelRenderer.spec.ts +++ b/web/src/components/dashboards/panels/LogsPanelRenderer.spec.ts @@ -102,6 +102,12 @@ describe("LogsPanelRenderer", () => { expect(table().props("pinnedFirstColumn")).toBe("_timestamp"); }); + it("leaves the empty state to the panel so no second No Data message stacks under it", async () => { + await mountPanel({}, []); + expect(table().exists()).toBe(false); + expect(wrapper!.find('[data-test="o2-table-empty"]').exists()).toBe(false); + }); + it("colours each row's severity rail from the level field, like the logs table", async () => { await mountPanel(); const data = table().props("data") as any[]; diff --git a/web/src/components/dashboards/panels/LogsPanelRenderer.vue b/web/src/components/dashboards/panels/LogsPanelRenderer.vue index ec1603411a..4e32609411 100644 --- a/web/src/components/dashboards/panels/LogsPanelRenderer.vue +++ b/web/src/components/dashboards/panels/LogsPanelRenderer.vue @@ -16,7 +16,9 @@ along with this program. If not, see . + + + {{ isDataView ? t("panel.showChart") : t("panel.showRawData") }} + . @series-data-update="onCuratedSeriesData" @send-to-ai-chat="(value, append) => $emit('sendToAiChat', value, append)" > +
props.data?.type, + renderer: PanleSchemaRendererRef, + enabled: () => !props.kiosk && !props.viewOnly && !props.simplifiedPanelView, + }); const exemplarsEligible = computed(() => isExemplarEligible(props.data)); const exemplarKey = computed(() => @@ -986,6 +1020,10 @@ export default defineComponent({ // for full screen button const isCurrentlyHoveredPanel: any = ref(false); const isLayoutEditing = computed(() => props.layoutMode === "edit"); + // Layout editing hides the panel bar controls, which would leave the Data view with no way back. + watch(isLayoutEditing, (editing) => { + if (editing) previewView.value = "chart"; + }); // Editor actions follow the "Edit panel" menu item; Refresh is a viewer action, so layout edit and viewOnly keep it. const canEditFromNotice = computed( @@ -1512,6 +1550,11 @@ export default defineComponent({ isCurrentlyHoveredPanel, isLayoutEditing, hoverRevealClass, + previewView, + showDataViewToggle, + isDataView, + showDataOverlay, + previewRawData, hasNoticeChips, showViewPanel, dependentAdHocVariable, diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts index e83a938bf8..3603ef851d 100644 --- a/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts @@ -1087,6 +1087,47 @@ describe("PanelEditor.vue", () => { }); }); + describe("Preview Chart | Data view", () => { + const TOGGLE = { name: "PanelViewToggle" }; + + beforeEach(() => { + mockChartData.value = {}; + mockDashboardPanelData.data.type = "line"; + }); + + afterEach(() => { + mockChartData.value = undefined; + }); + + const mountEditor = (pageType = "dashboard") => + shallowMount(PanelEditor, { props: { pageType: pageType as any }, global: mountGlobal }); + + it("offers the switch on a dashboard chart and starts on Chart", () => { + wrapper = mountEditor(); + expect(wrapper.findComponent(TOGGLE).exists()).toBe(true); + expect(wrapper.findComponent({ name: "PanelDataView" }).exists()).toBe(false); + }); + + it("hides the switch for table panels and outside dashboards", () => { + mockDashboardPanelData.data.type = "table"; + wrapper = mountEditor(); + expect(wrapper.findComponent(TOGGLE).exists()).toBe(false); + wrapper.unmount(); + + mockDashboardPanelData.data.type = "line"; + wrapper = mountEditor("metrics"); + expect(wrapper.findComponent(TOGGLE).exists()).toBe(false); + }); + + it("overlays the raw rows when Data is picked", async () => { + wrapper = mountEditor(); + wrapper.findComponent({ name: "PanelViewToggle" }).vm.$emit("update:modelValue", "data"); + await nextTick(); + expect(wrapper.findComponent({ name: "PanelDataView" }).exists()).toBe(true); + expect(wrapper.findComponent({ name: "PanelSchemaRenderer" }).exists()).toBe(true); + }); + }); + describe("renderer errors", () => { beforeEach(() => { mockChartData.value = {}; diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.vue b/web/src/components/dashboards/PanelEditor/PanelEditor.vue index 4abe3e88a4..812db39c96 100644 --- a/web/src/components/dashboards/PanelEditor/PanelEditor.vue +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.vue @@ -197,8 +197,16 @@ along with this program. If not, see .
+ + . @format-column="openColumnFormatting" />
+ +
@@ -808,6 +826,9 @@ import DashboardQueryBuilder from "@/components/dashboards/addPanel/DashboardQue import DashboardErrorsComponent from "@/components/dashboards/addPanel/DashboardErrors.vue"; import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue"; import PanelErrorButtons from "@/components/dashboards/PanelErrorButtons.vue"; +import PanelDataView from "@/components/dashboards/addPanel/PanelDataView.vue"; +import PanelViewToggle from "@/components/dashboards/PanelViewToggle.vue"; +import { usePanelDataView } from "@/composables/dashboard/usePanelDataView"; import { usePanelEditorFocus } from "@/composables/dashboard/usePanelEditorFocus"; import type { PanelNoticeActionId } from "@/utils/dashboard/panelNotices"; import OButton from "@/lib/core/Button/OButton.vue"; @@ -1017,6 +1038,18 @@ const seriesOverridePreviewSource = computed(() => { }; }); +const { + previewView, + showDataViewToggle, + isDataView, + showDataOverlay, + rawData: previewRawData, +} = usePanelDataView({ + panelType: () => dashboardPanelData.data.type, + renderer: panelSchemaRendererRef, + enabled: () => props.pageType === "dashboard", +}); + // The desktop 20% field-list splitter is unusable on a phone, so there it opens at ~half width. const { isMobile } = useBreakpoint(); const mobileFieldsOpen = ref(false); diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index 3fd447790b..91098a8719 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -2631,6 +2631,7 @@ export default defineComponent({ chartPanelRef, chartRendererRef, data, + filteredData, loading, searchRequestTraceIds, errorDetail, diff --git a/web/src/components/dashboards/PanelViewToggle.vue b/web/src/components/dashboards/PanelViewToggle.vue new file mode 100644 index 0000000000..cd429f8128 --- /dev/null +++ b/web/src/components/dashboards/PanelViewToggle.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/web/src/components/dashboards/addPanel/PanelDataView.spec.ts b/web/src/components/dashboards/addPanel/PanelDataView.spec.ts new file mode 100644 index 0000000000..28f92c9fbb --- /dev/null +++ b/web/src/components/dashboards/addPanel/PanelDataView.spec.ts @@ -0,0 +1,101 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import i18n from "@/locales"; +import PanelDataView from "./PanelDataView.vue"; + +const mountView = (props: Record) => + mount(PanelDataView, { + props: { dataTestPrefix: "test", ...props }, + global: { plugins: [i18n] }, + }); + +describe("PanelDataView", () => { + it("shows the query result columns and rows", () => { + const wrapper = mountView({ + data: [ + [ + { x_axis_1: "api", "a.b": 3 }, + { x_axis_1: "web", "a.b": 4 }, + ], + ], + }); + const table = wrapper.findComponent({ name: "OTable" }); + expect(table.props("columns").map((c: any) => c.id)).toEqual(["x_axis_1", "a.b"]); + expect(table.props("data")).toHaveLength(2); + expect(table.props("columns")[1].accessorFn(table.props("data")[0])).toBe(3); + }); + + it("gives duplicate rows distinct keys", () => { + const row = { x_axis_1: "api", y_axis_1: 1 }; + const wrapper = mountView({ data: [[row, { ...row }]] }); + const table = wrapper.findComponent({ name: "OTable" }); + const rowKey = table.props("rowKey") as (r: any) => string; + const [a, b] = table.props("data"); + expect(rowKey(a)).not.toBe(rowKey(b)); + }); + + it("flattens PromQL range and instant series into timestamp, value and labels", () => { + const wrapper = mountView({ + queryType: "promql", + data: [ + { + result: [ + { metric: { job: "api" }, values: [[1, "5"]] }, + { metric: { job: "web" }, value: [2, "7"] }, + ], + }, + ], + }); + const table = wrapper.findComponent({ name: "OTable" }); + expect(table.props("columns").map((c: any) => c.id)).toEqual(["timestamp", "value", "job"]); + expect(table.props("data")).toHaveLength(2); + }); + + it("shows its own empty message when the query returned nothing", () => { + const table = mountView({ data: [[]] }).findComponent({ name: "OTable" }); + expect(table.props("data")).toHaveLength(0); + expect(table.props("emptyMessage")).toBe("No rows returned"); + }); + + it("keeps the rows on screen while a refresh streams in", () => { + const table = mountView({ data: [[{ a: 1 }]], loading: true }).findComponent({ + name: "OTable", + }); + expect(table.props("loading")).toBe(false); + expect(table.props("streaming")).toBe(true); + }); + + it("caps very large results and says so", () => { + const rows = Array.from({ length: 100005 }, (_, i) => ({ i })); + // jsdom has no layout, so a real virtual-scrolled OTable would draw every row. + const wrapper = mount(PanelDataView, { + props: { dataTestPrefix: "test", data: [rows] }, + global: { + plugins: [i18n], + stubs: { OTable: { name: "OTable", props: ["data"], template: "
" } }, + }, + }); + expect(wrapper.findComponent({ name: "OTable" }).props("data")).toHaveLength(100000); + expect(wrapper.find('[data-test="test-data-view-capped"]').text()).toContain("100,005"); + }); + + it("aligns a numeric column right even when its first value is empty", () => { + const table = mountView({ data: [[{ n: null }, { n: 4 }]] }).findComponent({ name: "OTable" }); + expect(table.props("columns")[0].meta.align).toBe("right"); + }); +}); diff --git a/web/src/components/dashboards/addPanel/PanelDataView.vue b/web/src/components/dashboards/addPanel/PanelDataView.vue new file mode 100644 index 0000000000..ed79f6754c --- /dev/null +++ b/web/src/components/dashboards/addPanel/PanelDataView.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts b/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts index ab6f01f23b..637f8fba9a 100644 --- a/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts +++ b/web/src/components/dashboards/viewPanel/ViewPanel.spec.ts @@ -1031,4 +1031,37 @@ describe("ViewPanel", () => { expect(popup.props("open")).toBe(false); }); }); + + describe("Chart | Data view", () => { + const dataViewStub = { + PanelDataView: { template: '
' }, + }; + + afterEach(() => { + mockDashboardPanelData.data.type = "line"; + }); + + it("overlays the raw rows on the chart when Data is picked", async () => { + wrapper = createWrapper({}, dataViewStub); + wrapper.vm.chartData = mockDashboardPanelData.data; + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-test="dashboard-viewpanel-data-view"]').exists()).toBe(false); + await wrapper.find('[data-test="dashboard-viewpanel-preview-view-data"]').trigger("click"); + expect(wrapper.find('[data-test="dashboard-viewpanel-data-view"]').exists()).toBe(true); + expect(wrapper.find('[data-test="dashboard-viewpanel-panel-schema-renderer"]').exists()).toBe( + true, + ); + }); + + it("offers no Data view for a table panel", async () => { + mockDashboardPanelData.data.type = "table"; + wrapper = createWrapper({}, dataViewStub); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-test="dashboard-viewpanel-preview-view-toggle"]').exists()).toBe( + false, + ); + }); + }); }); diff --git a/web/src/components/dashboards/viewPanel/ViewPanel.vue b/web/src/components/dashboards/viewPanel/ViewPanel.vue index 415410eb01..ec7674085b 100644 --- a/web/src/components/dashboards/viewPanel/ViewPanel.vue +++ b/web/src/components/dashboards/viewPanel/ViewPanel.vue @@ -185,6 +185,12 @@ class="me-2 flex shrink-0 items-center justify-end" data-test="view-panel-last-refreshed-at" > +
- +
+ + +
dashboardPanelData.data.type, + renderer: panelSchemaRendererRef, + }); // The applied range the chart loads with; the picker's own value is `timeGate.picked`. const selectedDate: any = ref(props.selectedDateForViewPanel); const dateTimePickerRef: any = ref(null); @@ -1073,6 +1103,10 @@ export default defineComponent({ showLegendsDialog, currentPanelData, panelSchemaRendererRef, + previewView, + showDataViewToggle, + showDataOverlay, + previewRawData, isPartialData, isPanelLoading, isCachedDataDifferWithCurrentTimeRange, diff --git a/web/src/composables/dashboard/usePanelActions.spec.ts b/web/src/composables/dashboard/usePanelActions.spec.ts index 63ef363a07..c050d062e7 100644 --- a/web/src/composables/dashboard/usePanelActions.spec.ts +++ b/web/src/composables/dashboard/usePanelActions.spec.ts @@ -16,7 +16,12 @@ import { afterEach, describe, expect, it, beforeEach, vi } from "vitest"; import { setLanguage } from "@/utils/cookies"; import { gt } from "@/types/i18n"; -import { wrapCsvValue, usePanelAlertCreation, usePanelDownload } from "./usePanelActions"; +import { + flattenPanelResult, + wrapCsvValue, + usePanelAlertCreation, + usePanelDownload, +} from "./usePanelActions"; import { downloadFile } from "@/utils/dom"; import { readAlertPrefill } from "@/utils/alerts/alertPrefillStorage"; import { buildForecastAlertPromql, parseForecastAlertPromql } from "@/utils/alerts/forecastAlert"; @@ -69,6 +74,51 @@ describe("usePanelActions", () => { }); }); + describe("flattenPanelResult", () => { + it("joins every SQL dataset and keeps columns in first-seen order", () => { + const out = flattenPanelResult([ + [{ x_axis_1: "a", y_axis_1: 1 }], + [{ x_axis_1: "b", y_axis_1: 2, breakdown_1: "z" }], + ]); + expect(out.columns).toEqual(["x_axis_1", "y_axis_1", "breakdown_1"]); + expect(out.rows).toHaveLength(2); + }); + + it("turns PromQL series into one row per point with its labels", () => { + const out = flattenPanelResult( + [ + { + result: [ + { + metric: { job: "api" }, + values: [ + [1, "5"], + [2, "6"], + ], + }, + ], + }, + ], + "promql", + ); + expect(out.columns).toEqual(["timestamp", "value", "job"]); + expect(out.rows[1]).toEqual({ timestamp: 2, value: "6", job: "api" }); + }); + + it("reads the single value pair of a PromQL instant query", () => { + const out = flattenPanelResult( + [{ result: [{ metric: { job: "api" }, value: [3, "9"] }] }], + "promql", + ); + expect(out.rows).toEqual([{ timestamp: 3, value: "9", job: "api" }]); + }); + + it("returns nothing for a missing or malformed response", () => { + expect(flattenPanelResult(undefined)).toEqual({ columns: [], rows: [] }); + expect(flattenPanelResult([null, {}], "promql")).toEqual({ columns: [], rows: [] }); + }); + }); + describe("usePanelAlertCreation", () => { const makeBase = () => { const panelSchema = { @@ -975,6 +1025,29 @@ describe("usePanelActions", () => { ); }); + it("exports a PromQL instant result as CSV", () => { + const deps = makeDeps(); + deps.panelSchema.value.queryType = "promql"; + (downloadFile as any).mockReturnValue(true); + usePanelDownload(deps as any).downloadDataAsCSV("instant"); + + expect(downloadFile).toHaveBeenCalledWith( + "instant.csv", + "timestamp,value\r\n1,10", + "text/csv", + ); + }); + + it("reports no data instead of an empty PromQL CSV", () => { + const deps = makeDeps(); + deps.panelSchema.value.queryType = "promql"; + deps.filteredData.value = [{ result: [] }]; + usePanelDownload(deps as any).downloadDataAsCSV("empty"); + + expect(downloadFile).not.toHaveBeenCalled(); + expect(deps.showErrorNotification).toHaveBeenCalledWith("No data available to download"); + }); + it("exports PromQL filtered data as JSON", () => { const deps = makeDeps(); deps.panelSchema.value.queryType = "promql"; diff --git a/web/src/composables/dashboard/usePanelActions.ts b/web/src/composables/dashboard/usePanelActions.ts index f41509b509..4871f19aba 100644 --- a/web/src/composables/dashboard/usePanelActions.ts +++ b/web/src/composables/dashboard/usePanelActions.ts @@ -73,6 +73,34 @@ export const forecastPointOf = (series: any, dataIndex: number | undefined) => { }; }; +/** Flattens a panel query response into rows plus their columns in first-seen order. */ +export const flattenPanelResult = ( + data: any, + queryType?: string, +): { columns: string[]; rows: Record[] } => { + const rows: Record[] = []; + if (queryType === "promql") { + (data ?? []).forEach((promData: any) => { + if (!Array.isArray(promData?.result)) return; + promData.result.forEach((series: any) => { + // Instant queries return one `value` pair instead of a `values` range. + const points = series.values ?? (series.value ? [series.value] : []); + points.forEach((point: any) => { + rows.push({ timestamp: point[0], value: point[1], ...(series.metric ?? {}) }); + }); + }); + }); + } else { + (data ?? []).forEach((dataset: any) => { + if (!Array.isArray(dataset)) return; + rows.push(...dataset); + }); + } + const columns = new Set(); + rows.forEach((row) => Object.keys(row).forEach((key) => columns.add(key))); + return { columns: Array.from(columns), rows }; +}; + /** The SQL column a right-clicked threshold applies to, read off the query's y-axis or its SQL. */ const sqlYAxisColumnOf = ( query: any, @@ -367,102 +395,33 @@ export function usePanelDownload({ return; } - let csvContent; + const isPromql = panelSchema.value.queryType === "promql"; + // PromQL reads filteredData so hidden queries stay out of the export. + const { columns, rows } = flattenPanelResult( + isPromql ? filteredData?.value : data?.value, + panelSchema.value.queryType, + ); - // Check if this is a PromQL query type panel - if (panelSchema.value.queryType === "promql") { - // Handle PromQL data format - const flattenedData: any[] = []; - - // Iterate through each response item (multiple queries can produce multiple responses) - // Use filteredData to exclude hidden queries - filteredData?.value?.forEach((promData: any) => { - if (!promData?.result || !Array.isArray(promData.result)) return; - - // Iterate through each result (time series) - promData.result.forEach((series: any) => { - const metricLabels = series.metric || {}; - - // Iterate through values array (timestamp, value pairs) - series.values.forEach((point: any) => { - const timestamp = point[0]; - const value = point[1]; - - // Create a row with timestamp, value, and all metric labels - const row = { - timestamp: timestamp, - value: value, - ...metricLabels, - }; - - flattenedData.push(row); - }); - }); - }); - - // Get all unique keys across all data points - const allKeys = new Set(); - flattenedData.forEach((row: any) => { - Object.keys(row).forEach((key: any) => allKeys.add(key)); - }); - - // Convert Set to Array and ensure timestamp and value come first - const keys = Array.from(allKeys); - keys.sort((a: any, b: any) => { - if (a === "timestamp") return -1; - if (b === "timestamp") return 1; - if (a === "value") return -1; - if (b === "value") return 1; - return a.localeCompare(b); - }); - - // Create CSV content - csvContent = [ - keys.join(","), // Headers row - ...flattenedData.map((row: any) => - keys.map((key: any) => wrapCsvValue(row[key] ?? "")).join(","), - ), - ].join("\r\n"); - } else { - // Handle standard SQL format - now supporting multiple arrays - const flattenedData: any[] = []; - - // Iterate through all datasets/arrays in the response - data?.value?.forEach((dataset: any) => { - // Skip if dataset is empty or not an array - if (!dataset || !Array.isArray(dataset) || dataset.length === 0) return; - - dataset.forEach((row: any) => { - flattenedData.push({ - ...row, - }); - }); - }); - - // If after flattening we have no data, show notification and return - if (flattenedData.length === 0) { - showErrorNotification(t("dashboard.noDataAvailableToDownload")); - return; - } - - // Collect all possible keys from all objects - const allKeys = new Set(); - flattenedData.forEach((row: any) => { - Object.keys(row).forEach((key: any) => allKeys.add(key)); - }); - - // Convert Set to Array and sort for consistent order - const headers = Array.from(allKeys).sort(); - - // Create CSV content with headers and data rows - csvContent = [ - headers.join(","), // Headers row - ...flattenedData.map((row: any) => - headers.map((header: any) => wrapCsvValue(row[header] ?? "")).join(","), - ), - ].join("\r\n"); + if (rows.length === 0) { + showErrorNotification(t("dashboard.noDataAvailableToDownload")); + return; } + const headers = isPromql + ? columns.sort((a, b) => { + if (a === "timestamp") return -1; + if (b === "timestamp") return 1; + if (a === "value") return -1; + if (b === "value") return 1; + return a.localeCompare(b); + }) + : columns.sort(); + + const csvContent = [ + headers.join(","), + ...rows.map((row) => headers.map((key) => wrapCsvValue(row[key] ?? "")).join(",")), + ].join("\r\n"); + const status = downloadFile((title ?? "chart-export") + ".csv", csvContent, "text/csv"); if (status === true) { diff --git a/web/src/composables/dashboard/usePanelDataView.spec.ts b/web/src/composables/dashboard/usePanelDataView.spec.ts new file mode 100644 index 0000000000..8ef3dfaa43 --- /dev/null +++ b/web/src/composables/dashboard/usePanelDataView.spec.ts @@ -0,0 +1,65 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; +import { ref } from "vue"; +import { usePanelDataView } from "./usePanelDataView"; + +describe("usePanelDataView", () => { + const renderer = ref({ data: [["all"]], filteredData: [["visible"]] }); + + it("starts on Chart and switches to Data", () => { + const view = usePanelDataView({ panelType: "line", renderer }); + expect(view.showDataViewToggle.value).toBe(true); + expect(view.isDataView.value).toBe(false); + view.previewView.value = "data"; + expect(view.isDataView.value).toBe(true); + }); + + it.each(["table", "logs", "html", "markdown", "alert-list", "custom_chart"])( + "offers no Data view for %s", + (type) => { + const view = usePanelDataView({ panelType: type, renderer }); + view.previewView.value = "data"; + expect(view.showDataViewToggle.value).toBe(false); + expect(view.isDataView.value).toBe(false); + }, + ); + + it("respects the enabled flag", () => { + const view = usePanelDataView({ + panelType: "line", + queryType: "sql", + renderer, + enabled: false, + }); + expect(view.showDataViewToggle.value).toBe(false); + }); + + it("reads the legend-filtered result so hidden queries stay out", () => { + const view = usePanelDataView({ panelType: "line", renderer }); + expect(view.rawData.value).toEqual([["visible"]]); + }); + + it("keeps the error on screen instead of overlaying rows", () => { + const failing = ref({ filteredData: [], errorDetail: { message: "boom" } }); + const view = usePanelDataView({ panelType: "line", renderer: failing }); + view.previewView.value = "data"; + expect(view.isDataView.value).toBe(true); + expect(view.showDataOverlay.value).toBe(false); + failing.value = { filteredData: [], errorDetail: { message: "" } }; + expect(view.showDataOverlay.value).toBe(true); + }); +}); diff --git a/web/src/composables/dashboard/usePanelDataView.ts b/web/src/composables/dashboard/usePanelDataView.ts new file mode 100644 index 0000000000..44d160ab2e --- /dev/null +++ b/web/src/composables/dashboard/usePanelDataView.ts @@ -0,0 +1,50 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { computed, ref, toValue, type MaybeRefOrGetter } from "vue"; + +export type PanelPreviewView = "chart" | "data"; + +// Already tabular, or not query-driven, so a raw-rows view adds nothing. +const NO_DATA_VIEW_TYPES = ["table", "logs", "html", "markdown", "alert-list", "custom_chart"]; + +/** Per-viewer Chart | Data switch state for a rendered panel; never saved with the panel. */ +export function usePanelDataView({ + panelType, + renderer, + enabled = true, +}: { + panelType: MaybeRefOrGetter; + renderer: MaybeRefOrGetter; + enabled?: MaybeRefOrGetter; +}) { + const previewView = ref("chart"); + + const showDataViewToggle = computed( + () => toValue(enabled) && !NO_DATA_VIEW_TYPES.includes(toValue(panelType) ?? ""), + ); + + const isDataView = computed(() => showDataViewToggle.value && previewView.value === "data"); + + // A failed query keeps its error on screen; its data may be the panel's error placeholder. + const showDataOverlay = computed( + () => isDataView.value && !toValue(renderer)?.errorDetail?.message, + ); + + // filteredData drops queries hidden in the legend, so the rows match the chart. + const rawData = computed(() => toValue(renderer)?.filteredData); + + return { previewView, showDataViewToggle, isDataView, showDataOverlay, rawData }; +} diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json index 1bae5592e1..b54975467c 100644 --- a/web/src/locales/languages/ar-SA.json +++ b/web/src/locales/languages/ar-SA.json @@ -9401,6 +9401,13 @@ "rowFields": "حقول الصف", "valueFields": "حقول القيمة", "noData": "لا توجد بيانات", + "previewView": "عرض المعاينة", + "previewChart": "مخطط", + "previewData": "بيانات", + "showRawData": "عرض البيانات الخام", + "showChart": "عرض المخطط", + "dataViewEmpty": "لم يتم إرجاع أي صفوف", + "dataViewCapped": "عرض أول {shown} من {total} صف", "logExplorer": { "title": "مستكشف السجلات", "openInLogs": "فتح في السجلات", diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json index e191cb0be3..2bb608e727 100644 --- a/web/src/locales/languages/de-DE.json +++ b/web/src/locales/languages/de-DE.json @@ -9401,6 +9401,13 @@ "rowFields": "Zeilenfelder", "valueFields": "Wertfelder", "noData": "Keine Daten", + "previewView": "Vorschauansicht", + "previewChart": "Diagramm", + "previewData": "Daten", + "showRawData": "Rohdaten anzeigen", + "showChart": "Diagramm anzeigen", + "dataViewEmpty": "Keine Zeilen zurückgegeben", + "dataViewCapped": "Die ersten {shown} von {total} Zeilen werden angezeigt", "logExplorer": { "title": "Logs-Explorer", "openInLogs": "In Logs öffnen", diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index 0fdcdcd81e..b36cabb42f 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -9399,6 +9399,13 @@ "rowFields": "Row Fields", "valueFields": "Value Fields", "noData": "No Data", + "previewView": "Preview view", + "previewChart": "Chart", + "previewData": "Data", + "showRawData": "Show raw data", + "showChart": "Show chart", + "dataViewEmpty": "No rows returned", + "dataViewCapped": "Showing the first {shown} of {total} rows", "logExplorer": { "title": "Logs Explorer", "openInLogs": "Open in Logs", diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json index 815afca995..8d9c38ef4f 100644 --- a/web/src/locales/languages/es-ES.json +++ b/web/src/locales/languages/es-ES.json @@ -9401,6 +9401,13 @@ "rowFields": "Campos de fila", "valueFields": "Campos de valor", "noData": "Sin datos", + "previewView": "Vista previa", + "previewChart": "Gráfico", + "previewData": "Datos", + "showRawData": "Mostrar datos sin procesar", + "showChart": "Mostrar gráfico", + "dataViewEmpty": "No se devolvieron filas", + "dataViewCapped": "Mostrando las primeras {shown} de {total} filas", "logExplorer": { "title": "Explorador de logs", "openInLogs": "Abrir en Logs", diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json index 537d630ed2..66f1f5d00f 100644 --- a/web/src/locales/languages/fr-FR.json +++ b/web/src/locales/languages/fr-FR.json @@ -9401,6 +9401,13 @@ "rowFields": "Champs de ligne", "valueFields": "Champs de valeur", "noData": "Aucune donnée", + "previewView": "Vue de l'aperçu", + "previewChart": "Graphique", + "previewData": "Données", + "showRawData": "Afficher les données brutes", + "showChart": "Afficher le graphique", + "dataViewEmpty": "Aucune ligne renvoyée", + "dataViewCapped": "Affichage des {shown} premières lignes sur {total}", "logExplorer": { "title": "Explorateur de Logs", "openInLogs": "Ouvrir dans Logs", diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json index 49caa64a5e..65a9aa1cc0 100644 --- a/web/src/locales/languages/it-IT.json +++ b/web/src/locales/languages/it-IT.json @@ -9401,6 +9401,13 @@ "rowFields": "Campi riga", "valueFields": "Campi valore", "noData": "Nessun dato", + "previewView": "Vista anteprima", + "previewChart": "Grafico", + "previewData": "Dati", + "showRawData": "Mostra dati grezzi", + "showChart": "Mostra grafico", + "dataViewEmpty": "Nessuna riga restituita", + "dataViewCapped": "Visualizzazione delle prime {shown} righe su {total}", "logExplorer": { "title": "Esplora log", "openInLogs": "Apri nei log", diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json index a59ffec8ba..3db6829db5 100644 --- a/web/src/locales/languages/ja-JP.json +++ b/web/src/locales/languages/ja-JP.json @@ -9401,6 +9401,13 @@ "rowFields": "行フィールド", "valueFields": "値フィールド", "noData": "データなし", + "previewView": "プレビュー表示", + "previewChart": "チャート", + "previewData": "データ", + "showRawData": "生データを表示", + "showChart": "チャートを表示", + "dataViewEmpty": "返された行はありません", + "dataViewCapped": "{total} 行のうち最初の {shown} 行を表示しています", "logExplorer": { "title": "ログエクスプローラー", "openInLogs": "ログで開く", diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json index 762a8539c2..66c87976ff 100644 --- a/web/src/locales/languages/ko-KR.json +++ b/web/src/locales/languages/ko-KR.json @@ -9401,6 +9401,13 @@ "rowFields": "행 필드", "valueFields": "값 필드", "noData": "데이터 없음", + "previewView": "미리보기 보기", + "previewChart": "차트", + "previewData": "데이터", + "showRawData": "원시 데이터 표시", + "showChart": "차트 표시", + "dataViewEmpty": "반환된 행이 없습니다", + "dataViewCapped": "{total}개 행 중 처음 {shown}개 표시 중", "logExplorer": { "title": "로그 탐색기", "openInLogs": "로그에서 열기", diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json index 7b81b9770b..cf2c51693f 100644 --- a/web/src/locales/languages/nl-NL.json +++ b/web/src/locales/languages/nl-NL.json @@ -9401,6 +9401,13 @@ "rowFields": "Rijvelden", "valueFields": "Waardevelden", "noData": "Geen gegevens", + "previewView": "Voorbeeldweergave", + "previewChart": "Grafiek", + "previewData": "Gegevens", + "showRawData": "Ruwe gegevens tonen", + "showChart": "Grafiek tonen", + "dataViewEmpty": "Geen rijen geretourneerd", + "dataViewCapped": "De eerste {shown} van {total} rijen worden getoond", "logExplorer": { "title": "Logverkenner", "openInLogs": "Openen in Logs", diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json index 0e92391915..643be96396 100644 --- a/web/src/locales/languages/pl-PL.json +++ b/web/src/locales/languages/pl-PL.json @@ -9401,6 +9401,13 @@ "rowFields": "Pola wierszy", "valueFields": "Pola wartości", "noData": "Brak danych", + "previewView": "Widok podglądu", + "previewChart": "Wykres", + "previewData": "Dane", + "showRawData": "Pokaż surowe dane", + "showChart": "Pokaż wykres", + "dataViewEmpty": "Nie zwrócono żadnych wierszy", + "dataViewCapped": "Wyświetlanie pierwszych {shown} z {total} wierszy", "logExplorer": { "title": "Eksplorator logów", "openInLogs": "Otwórz w logach", diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json index 7767edbf51..b08293f354 100644 --- a/web/src/locales/languages/pt-PT.json +++ b/web/src/locales/languages/pt-PT.json @@ -9401,6 +9401,13 @@ "rowFields": "Campos de linha", "valueFields": "Campos de valor", "noData": "Sem dados", + "previewView": "Vista de pré-visualização", + "previewChart": "Gráfico", + "previewData": "Dados", + "showRawData": "Mostrar dados brutos", + "showChart": "Mostrar gráfico", + "dataViewEmpty": "Nenhuma linha devolvida", + "dataViewCapped": "A mostrar as primeiras {shown} de {total} linhas", "logExplorer": { "title": "Explorador de Logs", "openInLogs": "Abrir em Logs", diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json index bcdf4dda73..b571cd7925 100644 --- a/web/src/locales/languages/ru-RU.json +++ b/web/src/locales/languages/ru-RU.json @@ -9401,6 +9401,13 @@ "rowFields": "Поля строк", "valueFields": "Поля значений", "noData": "Нет данных", + "previewView": "Режим предпросмотра", + "previewChart": "График", + "previewData": "Данные", + "showRawData": "Показать исходные данные", + "showChart": "Показать график", + "dataViewEmpty": "Строки не возвращены", + "dataViewCapped": "Показаны первые {shown} из {total} строк", "logExplorer": { "title": "Просмотр логов", "openInLogs": "Открыть в логах", diff --git a/web/src/locales/languages/th-TH.json b/web/src/locales/languages/th-TH.json index 0d9e81872a..eada10dfd5 100644 --- a/web/src/locales/languages/th-TH.json +++ b/web/src/locales/languages/th-TH.json @@ -9399,6 +9399,13 @@ "rowFields": "Row Fields", "valueFields": "Value Fields", "noData": "No Data", + "previewView": "มุมมองตัวอย่าง", + "previewChart": "แผนภูมิ", + "previewData": "ข้อมูล", + "showRawData": "แสดงข้อมูลดิบ", + "showChart": "แสดงแผนภูมิ", + "dataViewEmpty": "ไม่มีแถวที่ส่งกลับมา", + "dataViewCapped": "แสดง {shown} แถวแรกจากทั้งหมด {total} แถว", "logExplorer": { "title": "Logs Explorer", "openInLogs": "Open in Logs", diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json index c89d637159..4c122b020a 100644 --- a/web/src/locales/languages/tr-TR.json +++ b/web/src/locales/languages/tr-TR.json @@ -9401,6 +9401,13 @@ "rowFields": "Satır Alanları", "valueFields": "Değer Alanları", "noData": "Veri Yok", + "previewView": "Önizleme görünümü", + "previewChart": "Grafik", + "previewData": "Veri", + "showRawData": "Ham veriyi göster", + "showChart": "Grafiği göster", + "dataViewEmpty": "Hiç satır döndürülmedi", + "dataViewCapped": "{total} satırın ilk {shown} tanesi gösteriliyor", "logExplorer": { "title": "Günlük Gezgini", "openInLogs": "Günlüklerde Aç", diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json index 576e8277c1..b437447976 100644 --- a/web/src/locales/languages/vi-VN.json +++ b/web/src/locales/languages/vi-VN.json @@ -9401,6 +9401,13 @@ "rowFields": "Trường hàng", "valueFields": "Trường giá trị", "noData": "Không có dữ liệu", + "previewView": "Chế độ xem trước", + "previewChart": "Biểu đồ", + "previewData": "Dữ liệu", + "showRawData": "Hiển thị dữ liệu thô", + "showChart": "Hiển thị biểu đồ", + "dataViewEmpty": "Không có hàng nào được trả về", + "dataViewCapped": "Đang hiển thị {shown} hàng đầu tiên trong số {total} hàng", "logExplorer": { "title": "Trình khám phá nhật ký", "openInLogs": "Mở trong Nhật ký", diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json index db0a6b5529..e87938f465 100644 --- a/web/src/locales/languages/zh-CN.json +++ b/web/src/locales/languages/zh-CN.json @@ -9401,6 +9401,13 @@ "rowFields": "行字段", "valueFields": "值字段", "noData": "无数据", + "previewView": "预览视图", + "previewChart": "图表", + "previewData": "数据", + "showRawData": "显示原始数据", + "showChart": "显示图表", + "dataViewEmpty": "未返回任何行", + "dataViewCapped": "显示 {total} 行中的前 {shown} 行", "logExplorer": { "title": "日志浏览器", "openInLogs": "在日志中打开", diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json index cec6209fc9..bd0f10982a 100644 --- a/web/src/locales/languages/zh-TW.json +++ b/web/src/locales/languages/zh-TW.json @@ -9396,6 +9396,13 @@ "rowFields": "列欄位", "valueFields": "值欄位", "noData": "無資料", + "previewView": "預覽檢視", + "previewChart": "圖表", + "previewData": "資料", + "showRawData": "顯示原始資料", + "showChart": "顯示圖表", + "dataViewEmpty": "未傳回任何資料列", + "dataViewCapped": "顯示 {total} 列中的前 {shown} 列", "logExplorer": { "title": "日誌瀏覽器", "openInLogs": "在日誌中開啟", From fe9723950c4068aa03532d4a03e7b45c9ff9dc77 Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Mon, 2 Nov 2026 17:31:58 +0000 Subject: [PATCH 9/9] test(fleet): API, RBAC, UI and real-collector coverage for fleet management (#16189) --- tests/api-testing/README.md | 13 + tests/api-testing/tests/edition/__init__.py | 0 .../tests/edition/test_fleet_edition_gate.py | 24 ++ tests/ui-testing/pages/apiCleanup.js | 44 ++ .../pages/fleetPages/fleetConfigPage.js | 203 +++++++++ .../pages/fleetPages/fleetFleetsPage.js | 65 +++ .../pages/fleetPages/fleetInstallPage.js | 301 ++++++++++++++ .../pages/fleetPages/fleetManagerPage.js | 219 ++++++++++ .../pages/fleetPages/fleetRolloutPage.js | 71 ++++ .../ui-testing/pages/fleetPages/fleetSeed.js | 392 ++++++++++++++++++ tests/ui-testing/pages/iamPages/rolesPage.js | 11 + tests/ui-testing/pages/page-manager.js | 14 + .../Fleet/fleet-access.spec.js | 161 +++++++ .../Fleet/fleet-config-deploy.spec.js | 174 ++++++++ .../Fleet/fleet-install.spec.js | 151 +++++++ .../Fleet/fleet-navigation.spec.js | 94 +++++ .../IAM/iam-roles-editor.spec.js | 15 + .../playwright-tests/cleanup.spec.js | 3 + 18 files changed, 1955 insertions(+) create mode 100644 tests/api-testing/tests/edition/__init__.py create mode 100644 tests/api-testing/tests/edition/test_fleet_edition_gate.py create mode 100644 tests/ui-testing/pages/fleetPages/fleetConfigPage.js create mode 100644 tests/ui-testing/pages/fleetPages/fleetFleetsPage.js create mode 100644 tests/ui-testing/pages/fleetPages/fleetInstallPage.js create mode 100644 tests/ui-testing/pages/fleetPages/fleetManagerPage.js create mode 100644 tests/ui-testing/pages/fleetPages/fleetRolloutPage.js create mode 100644 tests/ui-testing/pages/fleetPages/fleetSeed.js create mode 100644 tests/ui-testing/playwright-tests/Fleet/fleet-access.spec.js create mode 100644 tests/ui-testing/playwright-tests/Fleet/fleet-config-deploy.spec.js create mode 100644 tests/ui-testing/playwright-tests/Fleet/fleet-install.spec.js create mode 100644 tests/ui-testing/playwright-tests/Fleet/fleet-navigation.spec.js diff --git a/tests/api-testing/README.md b/tests/api-testing/README.md index 06d52f06fa..f285b9b9e6 100644 --- a/tests/api-testing/README.md +++ b/tests/api-testing/README.md @@ -119,6 +119,19 @@ file should pass strict ruff. | `serial` | Reads or changes server-wide state (root passcode, shared seed stream, org-wide counts). Jobs that run the default set with `-n` (OSS `api_integration_tests`, ENT `oss_in_ent`) run `-m "not serial" -n 4 --dist loadfile`, then `-m serial` alone; mark any new test of that kind. | | `order(N)` | Run order pin (legacy — avoid in new tests). | +## Enterprise suites with their own setup + +CI copies `o2-enterprise/tests/*` over `tests/` and runs these suites in their own shards, never in the full run. Locally, deselect them from `rye run pytest` with `--ignore=tests/fleet/ --ignore=tests/fleet_flag_off/ --ignore=tests/fleet_collector/ --ignore=tests/rbac/fleet/`. + +| Suite | Needs | Run | +|---|---|---| +| `tests/fleet/` | An enterprise server with `O2_FLEET_ENABLED=true` (the default) | `rye run pytest tests/fleet/ -n 4 --dist loadfile` | +| `tests/fleet_flag_off/` | Two starts over the same `./data`: first with `O2_FLEET_ENABLED=true` to seed a real token, then with `O2_FLEET_ENABLED=false` (fails otherwise) | `FLEET_FLAG_OFF_SEED=/tmp/seed.json PYTHONPATH=.:tests rye run python -m fleet_flag_off.seed_token`, restart the server with the flag off, then `FLEET_FLAG_OFF_SEED=/tmp/seed.json rye run pytest tests/fleet_flag_off/` | +| `tests/fleet_collector/` | An enterprise server with `O2_FLEET_ENABLED=true`, a running Docker daemon, and the collector image pinned as `IMAGE_REF` in `tests/fleet_collector/collector_docker.py` (pulled on first use). Containers reach the server through `host.docker.internal`. | `rye run pytest tests/fleet_collector/` (no `-n`) | +| `tests/rbac/fleet/` | An enterprise server with `O2_OPENFGA_ENABLED=true`, `O2_OPENFGA_LIST_ONLY_PERMITTED=true` (the filtered-list checks need it) and a running OpenFGA server; CI runs it in the `ent_rbac` shard | `rye run pytest tests/rbac/fleet/` | + +Fleet runs leave `fl_auto_*` orgs behind, because org delete is cloud-only. Most are empty, but the flag-off seed org keeps its fleet and live token, and the fleet suite's orgs keep a disabled ingestion token. Remove them by hand before reusing a local server. + ## Troubleshooting - **`KeyError: 'ZO_BASE_URL'` on collection** — `conftest.py` reads env vars at import. Export them before running pytest (even `--collect-only`). diff --git a/tests/api-testing/tests/edition/__init__.py b/tests/api-testing/tests/edition/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/api-testing/tests/edition/test_fleet_edition_gate.py b/tests/api-testing/tests/edition/test_fleet_edition_gate.py new file mode 100644 index 0000000000..c4fdc1421f --- /dev/null +++ b/tests/api-testing/tests/edition/test_fleet_edition_gate.py @@ -0,0 +1,24 @@ +"""Fleet management is served by the enterprise build only; the edition comes from `/config`, so both branches run.""" + +from __future__ import annotations + +from support.client import OpenObserveClient + +ENTERPRISE_BUILDS = ("enterprise", "cloud") + + +def test_fl_gate_02_04_fleet_routes_follow_the_edition(client: OpenObserveClient): + config = client.get("config") + assert config.status_code == 200, config.text + build_type = config.json()["build_type"] + fleets = client.get("fleet/fleets") + + if build_type in ENTERPRISE_BUILDS: + assert config.json()["fleet_enabled"] is True, config.json() + assert fleets.status_code == 200, fleets.text + assert "default" in {f["id"] for f in fleets.json()["items"]}, fleets.text + else: + assert build_type == "opensource", build_type + assert config.json()["fleet_enabled"] is False, config.json() + # The OSS router never registers the fleet routes (enterprise-only `fleet_routes`), so there is no 403 stub. + assert fleets.status_code == 404, fleets.text diff --git a/tests/ui-testing/pages/apiCleanup.js b/tests/ui-testing/pages/apiCleanup.js index 0da1f27f20..9fc1bd2999 100644 --- a/tests/ui-testing/pages/apiCleanup.js +++ b/tests/ui-testing/pages/apiCleanup.js @@ -3712,6 +3712,50 @@ class APICleanup { return removed; } + /** Crashed-run fleet leftovers; quiet on OSS, where the fleet routes are not registered and answer 404. */ + async cleanupFleetArtifacts(fleetPrefixes = ['fl_auto_'], configPrefixes = ['fl-auto-']) { + testLogger.info('Starting fleet artifact cleanup', { fleetPrefixes, configPrefixes }); + const headers = { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }; + const api = (path) => `${this.baseUrl}/api/${this.org}${path}`; + const json = async (res) => { try { return await res.json(); } catch { return null; } }; + const starts = (value, prefixes) => typeof value === 'string' && prefixes.some((p) => value.startsWith(p)); + const removed = { tokens: 0, fleets: 0, configs: 0 }; + // The fl_auto_ roles and users do not depend on the fleet API, so sweep them even when it is unavailable. + for (const prefix of fleetPrefixes) await this.cleanupIamArtifacts(prefix); + try { + const fleetsRes = await this._fetch(api('/fleet/fleets?offset=0&limit=1000'), { method: 'GET', headers }); + if (!fleetsRes.ok) { + testLogger.info('Fleet cleanup skipped — fleet API unavailable (OSS build or fleet off)', { status: fleetsRes.status }); + return removed; + } + const fleets = ((await json(fleetsRes))?.items ?? []).filter((f) => starts(f?.name, fleetPrefixes)); + const ids = new Set(fleets.map((f) => f.id)); + const tokensRes = await this._fetch(api('/fleet/tokens?offset=0&limit=1000'), { method: 'GET', headers }); + for (const tok of (tokensRes.ok ? (await json(tokensRes))?.items : null) ?? []) { + if (ids.has(tok?.fleet?.id) && !tok.revoked) { + await this._fetch(api(`/fleet/tokens/${tok.id}/revoke`), { method: 'POST', headers }); + removed.tokens += 1; + } + } + // Deleting a fleet also removes its revoked tokens and rollout history, which unpins its configurations. + for (const f of fleets) { + await this._fetch(api(`/fleet/fleets/${f.id}`), { method: 'DELETE', headers }); + removed.fleets += 1; + } + const configsRes = await this._fetch(api('/fleet/configs?offset=0&limit=1000'), { method: 'GET', headers }); + for (const c of (configsRes.ok ? (await json(configsRes))?.items : null) ?? []) { + if (starts(c?.id, configPrefixes)) { + await this._fetch(api(`/fleet/configs/${c.id}`), { method: 'DELETE', headers }); + removed.configs += 1; + } + } + testLogger.info('Fleet artifact cleanup complete', removed); + } catch (e) { + testLogger.warn('Fleet artifact cleanup hit an error — continuing', { error: e?.message }); + } + return removed; + } + /** * Verify stream exists via API * @param {string} streamName - Name of the stream to verify diff --git a/tests/ui-testing/pages/fleetPages/fleetConfigPage.js b/tests/ui-testing/pages/fleetPages/fleetConfigPage.js new file mode 100644 index 0000000000..d4de4a4b65 --- /dev/null +++ b/tests/ui-testing/pages/fleetPages/fleetConfigPage.js @@ -0,0 +1,203 @@ +const { expect } = require('@playwright/test'); +const testLogger = require('../../playwright-tests/utils/test-logger.js'); +const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js'); + +const PAGE_TIMEOUT_MS = 30000; + +class FleetConfigPage { + constructor(page) { + this.page = page; + this.editorPage = '[data-test="fleet-config-editor-page"]'; + this.backLink = '[data-test="app-page-header-back"]'; + this.modeChip = '[data-test="fleet-config-editor-mode-chip"]'; + this.visualMode = '[data-test="fleet-config-editor-mode-visual"]'; + // The YAML toggle item has no data-test of its own; reka renders its value on the button. + this.yamlMode = '[data-test="fleet-config-editor-mode"] button[value="yaml"]'; + this.yamlEditor = '[data-test="fleet-config-editor-yaml"]'; + this.canvas = '[data-test="fleet-editor-canvas"]'; + this.validateBtn = '[data-test="fleet-config-editor-validate"]'; + this.validationChip = '[data-test="fleet-config-editor-validation"]'; + this.publishBtn = '[data-test="fleet-config-editor-publish"]'; + this.publishDialog = '[data-test="fleet-config-editor-publish-dialog"]'; + this.publishMessageField = '[data-test="fleet-config-editor-publish-message-field"]'; + this.dialogPrimaryBtn = '[data-test="o-dialog-primary-btn"]'; + this.startRolloutBtn = '[data-test="fleet-config-editor-start-rollout"]'; + this.historyBtn = '[data-test="fleet-config-editor-history"]'; + this.history = '[data-test="fleet-config-history"]'; + this.diff = '[data-test="fleet-config-diff"]'; + this.properties = '[data-test="fleet-component-properties"]'; + this.newSettingKeyField = '[data-test="fleet-component-properties-new-key-field"]'; + this.newSettingValueField = '[data-test="fleet-component-properties-new-value-field"]'; + this.addSettingSubmit = '[data-test="fleet-component-properties-add-submit"]'; + this.successToast = '[data-test^="o-toast-"][data-test-variant="success"]'; + } + + paletteItem(kind, key) { + return this.page.locator(`[data-test="fleet-component-palette-item-${kind}-${key}"]`); + } + + node(nodeId) { + return this.page.locator(`[data-test=${JSON.stringify(`fleet-editor-node-${nodeId}`)}]`); + } + + // Dotted keys carry quotes and dots, so the attribute value is a quoted CSS string. + propertyField(key) { + return this.page.locator(`[data-test=${JSON.stringify(`fleet-component-properties-field-${key}-field`)}]`); + } + + historyCompare(version) { + return this.page.locator(`[data-test="fleet-config-history-compare-${version}"]`); + } + + async goto(configId) { + const url = `${process.env.ZO_BASE_URL}/web/fleet/configurations/${encodeURIComponent(configId)}?org_identifier=${getOrgIdentifier()}`; + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + await expect(this.page.locator(this.editorPage)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.validateBtn)).toBeEnabled({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectTitle(configId) { + await expect(this.page.locator(this.editorPage).getByRole('heading', { name: configId })).toBeVisible(); + } + + async clickBack() { + await this.page.locator(this.backLink).click(); + } + + async expectVisualMode() { + await expect(this.page.locator(this.visualMode)).toHaveAttribute('data-state', 'on', { timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.canvas)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async switchToYaml() { + if ((await this.page.locator(this.yamlMode).getAttribute('data-state')) !== 'on') await this.page.locator(this.yamlMode).click(); + await expect(this.page.locator(this.yamlMode)).toHaveAttribute('data-state', 'on'); + await expect(this.page.locator(this.yamlEditor)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async switchToVisual() { + await expect(this.page.locator(this.visualMode)).toBeEnabled({ timeout: PAGE_TIMEOUT_MS }); + await this.page.locator(this.visualMode).click(); + await this.expectVisualMode(); + } + + async expectVisualUnavailable() { + await expect(this.page.locator(this.visualMode)).toBeDisabled({ timeout: PAGE_TIMEOUT_MS }); + } + + async monaco(action, text) { + return await this.page.locator(this.yamlEditor).evaluate( + (node, { action, text }) => { + const editor = window.monaco?.editor?.getEditors?.().find((e) => node.contains(e.getDomNode())); + if (!editor) return null; + if (action === 'set') { + editor.setValue(text); + // The editor emits after a 500ms debounce that only a text blur flushes; unflushed, a quick switch to Visual drops the edit. + editor.focus(); + if (!node.contains(document.activeElement)) throw new Error('YAML editor did not take focus'); + document.activeElement.blur(); + } + return editor.getValue(); + }, + { action, text }, + ); + } + + async readYaml() { + await expect.poll(() => this.monaco('read'), { timeout: PAGE_TIMEOUT_MS }).not.toBeNull(); + return await this.monaco('read'); + } + + async expectYaml(expected) { + await expect.poll(async () => ((await this.monaco('read')) ?? '').trimEnd(), { timeout: PAGE_TIMEOUT_MS }).toBe( + expected.trimEnd(), + ); + } + + async setYaml(text) { + await expect.poll(() => this.monaco('read'), { timeout: PAGE_TIMEOUT_MS }).not.toBeNull(); + await this.monaco('set', text); + testLogger.info('YAML editor set', { lines: text.split('\n').length }); + } + + async addFromPalette(kind, key, nodeId) { + await this.paletteItem(kind, key).click(); + await expect(this.node(nodeId)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async selectNode(nodeId) { + await expect(this.node(nodeId)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await this.node(nodeId).click(); + await expect(this.page.locator(this.properties)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectPropertyValue(key, value) { + await expect(this.propertyField(key)).toHaveValue(value, { timeout: PAGE_TIMEOUT_MS }); + } + + async expectNoProperty(key) { + await expect(this.propertyField(key)).toHaveCount(0); + } + + async setProperty(key, value) { + await this.propertyField(key).fill(value); + await expect(this.propertyField(key)).toHaveValue(value); + } + + async addSetting(key, value) { + await this.page.locator(this.newSettingKeyField).fill(key); + await this.page.locator(this.newSettingValueField).fill(value); + await this.page.locator(this.addSettingSubmit).click(); + await this.expectPropertyValue(key, value); + } + + async validate() { + await this.page.locator(this.validateBtn).click(); + } + + async expectValidationChip(text) { + await expect(this.page.locator(this.validationChip)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS }); + } + + async publish(message) { + await this.page.locator(this.publishBtn).click(); + const dialog = this.page.locator(this.publishDialog); + await expect(dialog).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await dialog.locator(this.publishMessageField).fill(message); + await dialog.locator(this.dialogPrimaryBtn).click(); + } + + async expectModeChip(text) { + await expect(this.page.locator(this.modeChip)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS }); + } + + async expectSuccessToast(text) { + await expect(this.page.locator(this.successToast).filter({ hasText: text }).first()).toBeVisible({ + timeout: PAGE_TIMEOUT_MS, + }); + } + + async openHistory() { + await this.page.locator(this.historyBtn).click(); + await expect(this.page.locator(this.history)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectCompareDisabled(version) { + await expect(this.historyCompare(version)).toBeDisabled({ timeout: PAGE_TIMEOUT_MS }); + } + + async compare(version) { + await this.historyCompare(version).click(); + await expect(this.page.locator(this.diff)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectDiffContains(text) { + await expect(this.page.locator(this.diff)).toContainText(text); + } + + async openDeployFromEditor() { + await this.page.locator(this.startRolloutBtn).click(); + } +} + +module.exports = FleetConfigPage; diff --git a/tests/ui-testing/pages/fleetPages/fleetFleetsPage.js b/tests/ui-testing/pages/fleetPages/fleetFleetsPage.js new file mode 100644 index 0000000000..5cf4a45ba6 --- /dev/null +++ b/tests/ui-testing/pages/fleetPages/fleetFleetsPage.js @@ -0,0 +1,65 @@ +const { expect } = require('@playwright/test'); + +const PAGE_TIMEOUT_MS = 30000; +const DASH = '—'; + +class FleetFleetsPage { + constructor(page) { + this.page = page; + this.rail = '[data-test="fleet-rail"]'; + this.panel = '[data-test="fleet-fleets-panel"]'; + this.panelName = '[data-test="fleet-fleets-name"]'; + this.addCollectorBtn = '[data-test="fleet-fleets-add-collector"]'; + this.deployBtn = '[data-test="fleet-fleets-change-version"]'; + this.nameDialog = '[data-test="fleet-rail-name-dialog"]'; + this.nameDialogField = '[data-test="fleet-rail-name-dialog-name-field"]'; + this.dialogPrimaryBtn = '[data-test="o-dialog-primary-btn"]'; + this.rolloutsDrawer = '[data-test="fleet-rollouts-drawer"]'; + this.rolloutStatus = '[data-test="fleet-rollouts-status"]'; + } + + railItem(fleetId) { + return this.page.locator(`[data-test="fleet-rail-item-${fleetId}"]`); + } + + async expectPanelFor(name) { + await expect(this.page.locator(this.panel)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.panelName)).toHaveText(name); + } + + async expectRailItemActive(fleetId) { + await expect(this.railItem(fleetId)).toHaveAttribute('data-state', 'active', { timeout: PAGE_TIMEOUT_MS }); + } + + // The rail shows a collector count only for a fleet the caller may read, and a dash otherwise. + async expectRailItemReadable(fleetId, readable) { + const item = this.railItem(fleetId); + await expect(item).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + if (readable) await expect(item).not.toContainText(DASH); + else await expect(item).toContainText(DASH); + } + + async openAddCollector() { + await expect(this.page.locator(this.panel)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await this.page.locator(this.addCollectorBtn).click(); + } + + async openDeploy() { + await expect(this.page.locator(this.panel)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await this.page.locator(this.deployBtn).click(); + } + + async submitNewFleet(name) { + const dialog = this.page.locator(this.nameDialog); + await expect(dialog).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await dialog.locator(this.nameDialogField).fill(name); + await dialog.locator(this.dialogPrimaryBtn).click(); + } + + async expectRolloutDrawerStatus(text) { + await expect(this.page.locator(this.rolloutsDrawer)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.rolloutStatus)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS }); + } +} + +module.exports = FleetFleetsPage; diff --git a/tests/ui-testing/pages/fleetPages/fleetInstallPage.js b/tests/ui-testing/pages/fleetPages/fleetInstallPage.js new file mode 100644 index 0000000000..ec63bc1cde --- /dev/null +++ b/tests/ui-testing/pages/fleetPages/fleetInstallPage.js @@ -0,0 +1,301 @@ +const { expect } = require('@playwright/test'); +const testLogger = require('../../playwright-tests/utils/test-logger.js'); + +const PAGE_TIMEOUT_MS = 30000; +const SCRIPT_BASE = 'https://raw.githubusercontent.com/openobserve/agents/main'; +const IMAGE = 'o2cr.ai/openobserve/otel-fleet-collector'; +const HELM_NAMESPACE = 'openobserve-collector'; +const AGENT_RELEASE = 'otel-fleet-collector'; +const GATEWAY_RELEASE = 'otel-fleet-collector-gateway'; +const MASK = '•'.repeat(12); + +class FleetInstallPage { + constructor(page) { + this.page = page; + this.dialog = '[data-test="fleet-install-dialog"]'; + this.noToken = '[data-test="fleet-install-dialog-no-token"]'; + this.tokenTrigger = '[data-test="fleet-install-dialog-token-trigger"]'; + this.tokenOption = '[data-test="fleet-install-dialog-token-option"]'; + this.joinsText = '[data-test="fleet-install-dialog-fleet"]'; + this.copyOnce = '[data-test="fleet-install-dialog-copy-once"]'; + this.placeholderHint = '[data-test="fleet-install-dialog-placeholder-hint"]'; + this.waiting = '[data-test="fleet-install-dialog-waiting"]'; + this.tokenFormName = '[data-test="fleet-token-form-name-field"]'; + this.tokenFormSubmit = '[data-test="fleet-token-form-submit"]'; + this.command = '[data-test="fleet-install-command"]'; + this.commandCode = '[data-test="fleet-install-command-code-pre"] code'; + this.revealBtn = '[data-test="fleet-install-command-code-reveal-btn"]'; + this.copyBtn = '[data-test="fleet-install-command-code-copy-btn"]'; + this.websocket = '[data-test="fleet-install-command-websocket"] button[role="checkbox"]'; + this.clusterNameField = '[data-test="fleet-install-command-cluster-name-field"]'; + this.uninstallTrigger = '[data-test="fleet-install-command-uninstall"] button[aria-expanded]'; + this.uninstallCode = '[data-test="fleet-uninstall-command-code-pre"] code'; + } + + osButton(platform) { + return this.page.locator(`[data-test="fleet-install-command-os-${platform}"]`); + } + + roleButton(role) { + return this.page.locator(`[data-test="fleet-install-command-role-${role}"]`); + } + + static resolveBaseUrl(endpoint, origin) { + let url; + try { + url = new URL(endpoint || '/', origin); + } catch { + url = new URL(origin); + } + return `${url.origin}${url.pathname}`.replace(/\/+$/, ''); + } + + static opampEndpoint(t) { + const url = new URL(t.baseUrl); + const scheme = t.http ? url.protocol.replace(':', '') : url.protocol === 'https:' ? 'wss' : 'ws'; + return `${scheme}://${url.host}${url.pathname.replace(/\/+$/, '')}/api/${t.org}/v1/opamp`; + } + + static expectedInstallLines(platform, t) { + const q = (v) => `'${v.replace(/'/g, `'\\''`)}'`; + const join = (args, cont) => args.map((a, i) => ` ${a}${i < args.length - 1 ? cont : ''}`); + if (platform === 'linux' || platform === 'macos') { + const args = [ + `--url ${q(t.baseUrl)}`, + `--org ${q(t.org)}`, + `--token ${q(t.token)}`, + ...(t.version ? [`--version ${q(t.version)}`] : []), + `--transport ${t.http ? 'http' : 'ws'}`, + ]; + return [`curl -fsSL ${SCRIPT_BASE}/fleet/install.sh | sudo bash -s -- \\`, ...join(args, ' \\')]; + } + if (platform === 'windows') { + const ps = (v) => `'${v.replace(/'/g, "''")}'`; + const args = [ + `-Url ${ps(t.baseUrl)}`, + `-Org ${ps(t.org)}`, + `-Token ${ps(t.token)}`, + ...(t.version ? [`-Version ${ps(t.version)}`] : []), + `-Transport ${t.http ? 'http' : 'ws'}`, + ]; + return [`& ([scriptblock]::Create((irm ${SCRIPT_BASE}/fleet/install.ps1))) \``, ...join(args, ' `')]; + } + if (platform === 'docker') { + const baseUrl = t.baseUrl.replace(/\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, '//host.docker.internal'); + const insecure = !baseUrl.startsWith('https:'); + return [ + `AUTH=$(printf 'fleet:%s' ${q(t.token)} | base64 | tr -d '\\n')`, + 'mkdir -p otel-fleet && cat > otel-fleet/supervisor.yaml </dev/null 2>&1 || true', + 'docker run -d --name otel-fleet-collector --restart unless-stopped \\', + ' --hostname "$(hostname)" \\', + ' -v "$PWD/otel-fleet/supervisor.yaml:/etc/opampsupervisor/supervisor.yaml:ro" \\', + ' -v otel-fleet-state:/var/lib/opampsupervisor \\', + ` ${IMAGE}:${t.version ?? 'latest'}`, + ]; + } + const gateway = t.role === 'gateway'; + const cluster = t.clusterName?.trim().replace(/\\/g, '\\\\').replace(/,/g, '\\,'); + const sets = [ + ...(gateway ? ['--set agent.enabled=false', '--set gateway.enabled=true'] : []), + ...(cluster ? [`--set-string clusterName=${q(cluster)}`] : []), + `--set image.repository=${IMAGE}`, + ...(t.version ? [`--set image.tag=${q(t.version)}`] : []), + `--set opamp.endpoint=${q(FleetInstallPage.opampEndpoint(t))}`, + `--set opamp.token=${q(t.token)}`, + ]; + return [ + 'helm repo add openobserve https://charts.openobserve.ai && helm repo update', + `helm upgrade --install ${gateway ? GATEWAY_RELEASE : AGENT_RELEASE} openobserve/otel-fleet-collector \\`, + ` --namespace ${HELM_NAMESPACE} --create-namespace \\`, + ...join(sets, ' \\'), + ]; + } + + static expectedUninstallLines(platform, role = 'agent') { + if (platform === 'windows') return [`& ([scriptblock]::Create((irm ${SCRIPT_BASE}/fleet/install.ps1))) -Uninstall`]; + if (platform === 'docker') { + return ['docker rm -f otel-fleet-collector', 'docker volume rm otel-fleet-state', 'rm -rf otel-fleet']; + } + if (platform === 'kubernetes') { + return [`helm uninstall ${role === 'gateway' ? GATEWAY_RELEASE : AGENT_RELEASE} --namespace ${HELM_NAMESPACE}`]; + } + return [`curl -fsSL ${SCRIPT_BASE}/fleet/install.sh | sudo bash -s -- --uninstall`]; + } + + static maskToken(token) { + return token.slice(0, 4) + MASK + token.slice(-4); + } + + // The command targets the ingestion URL, or the app's own base when none is configured. + async installTarget(seed, token) { + const ingestion = await seed.ingestionBaseUrl(); + const url = new URL(this.page.url()); + const pos = url.pathname.indexOf('/web/'); + const appBase = pos > -1 ? url.origin + url.pathname.slice(0, pos) : url.origin; + const target = { + baseUrl: FleetInstallPage.resolveBaseUrl(ingestion || appBase, url.origin), + org: seed.orgId(), + token, + version: await seed.targetCollectorVersion(), + }; + testLogger.info('Install target', { baseUrl: target.baseUrl, org: target.org, version: target.version }); + return target; + } + + async expectDialogOpen() { + await expect(this.page.locator(this.dialog)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectCommandShown() { + await expect(this.page.locator(this.commandCode)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + // A single-choice toggle deselects its active item on a second click, so only an inactive item is clicked. + async selectPlatform(platform) { + if ((await this.osButton(platform).getAttribute('data-state')) !== 'on') await this.osButton(platform).click(); + await expect(this.osButton(platform)).toHaveAttribute('data-state', 'on'); + } + + async selectRole(role) { + if ((await this.roleButton(role).getAttribute('data-state')) !== 'on') await this.roleButton(role).click(); + await expect(this.roleButton(role)).toHaveAttribute('data-state', 'on'); + } + + async expectWebsocket(checked) { + await expect(this.page.locator(this.websocket)).toHaveAttribute('aria-checked', String(checked)); + } + + async setWebsocket(checked) { + const box = this.page.locator(this.websocket); + if ((await box.getAttribute('aria-checked')) !== String(checked)) await box.click(); + await this.expectWebsocket(checked); + } + + async revealToken() { + await this.page.locator(this.revealBtn).click(); + } + + async setClusterName(name) { + await this.page.locator(this.clusterNameField).fill(name); + } + + async commandText() { + return (await this.page.locator(this.commandCode).textContent()) ?? ''; + } + + async expectCommandLines(lines) { + await expect.poll(async () => (await this.commandText()).split('\n'), { timeout: PAGE_TIMEOUT_MS }).toEqual(lines); + } + + async expectCommandMasked(token) { + await expect.poll(() => this.commandText(), { timeout: PAGE_TIMEOUT_MS }).toContain(FleetInstallPage.maskToken(token)); + expect(await this.commandText()).not.toContain(token); + } + + async expandUninstall() { + const trigger = this.page.locator(this.uninstallTrigger).first(); + if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click(); + await expect(this.page.locator(this.uninstallCode)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectUninstallLines(lines, token) { + await expect + .poll(async () => ((await this.page.locator(this.uninstallCode).textContent()) ?? '').split('\n'), { + timeout: PAGE_TIMEOUT_MS, + }) + .toEqual(lines); + expect(await this.page.locator(this.uninstallCode).textContent()).not.toContain(token); + } + + async copyCommand() { + await this.page.locator(this.copyBtn).click(); + return await this.page.evaluate(() => navigator.clipboard.readText()); + } + + async expectNoTokenState() { + await expect(this.page.locator(this.noToken)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + // The dialog must read GET /fleet/collectors again once a token exists, or it never notices the collector. + async createInlineTokenAndAwaitCollectorsPoll(name) { + let created = false; + const onResponse = (resp) => { + if (resp.request().method() === 'POST' && /\/fleet\/tokens$/.test(new URL(resp.url()).pathname) && resp.ok()) { + created = true; + } + }; + this.page.on('response', onResponse); + try { + const createdResp = this.page.waitForResponse( + (r) => r.request().method() === 'POST' && /\/fleet\/tokens$/.test(new URL(r.url()).pathname), + ); + const polled = this.page.waitForResponse( + (r) => created && r.request().method() === 'GET' && /\/fleet\/collectors$/.test(new URL(r.url()).pathname), + { timeout: PAGE_TIMEOUT_MS }, + ); + await this.page.locator(this.noToken).locator(this.tokenFormName).fill(name); + await this.page.locator(this.noToken).locator(this.tokenFormSubmit).click(); + const resp = await createdResp; + expect(resp.status(), 'token create from the dialog failed').toBe(201); + const pollResp = await polled; + expect(pollResp.status()).toBe(200); + return await resp.json(); + } finally { + this.page.off('response', onResponse); + } + } + + async expectCopyOnceAndWaiting() { + await expect(this.page.locator(this.copyOnce)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.waiting)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.placeholderHint)).toHaveCount(0); + } + + async expectSelectedToken(label) { + await expect(this.page.locator(this.tokenTrigger)).toHaveAttribute('data-test-selected-label', label, { + timeout: PAGE_TIMEOUT_MS, + }); + } + + async tokenOptionLabels() { + const trigger = this.page.locator(this.tokenTrigger); + await expect(async () => { + if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'true', { timeout: 2000 }); + }).toPass({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.tokenOption).first()).toBeVisible(); + const labels = await this.page.locator(this.tokenOption).evaluateAll((els) => + els.map((e) => e.getAttribute('data-test-label')), + ); + await this.page.keyboard.press('Escape'); + return labels; + } + + async expectJoinsText(text) { + await expect(this.page.locator(this.joinsText)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS }); + } +} + +module.exports = FleetInstallPage; diff --git a/tests/ui-testing/pages/fleetPages/fleetManagerPage.js b/tests/ui-testing/pages/fleetPages/fleetManagerPage.js new file mode 100644 index 0000000000..7e2fec5ec2 --- /dev/null +++ b/tests/ui-testing/pages/fleetPages/fleetManagerPage.js @@ -0,0 +1,219 @@ +const { expect } = require('@playwright/test'); +const testLogger = require('../../playwright-tests/utils/test-logger.js'); +const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js'); + +const PAGE_TIMEOUT_MS = 30000; + +class FleetManagerPage { + constructor(page) { + this.page = page; + this.navDataTile = '[data-test="menu-link-\\/streams-item"]'; + this.navFleetItem = '[data-test="nav-group-item-fleet"]'; + this.managerPage = '[data-test="fleet-manager-page"]'; + this.flowOverview = '[data-test="fleet-flow-overview"]'; + this.flowError = '[data-test="fleet-flow-overview-error"]'; + this.flowNoFleets = '[data-test="fleet-flow-overview-no-fleets"]'; + this.fleetsArea = '[data-test="fleet-fleets"]'; + this.fleetsNoAccess = '[data-test="fleet-fleets-no-access"]'; + this.fleetsNotFound = '[data-test="fleet-fleets-not-found"]'; + this.fleetsPanel = '[data-test="fleet-fleets-panel"]'; + this.tableForbidden = '[data-test="o2-table-forbidden"]'; + this.collectorsScopeAll = '[data-test="fleet-collectors-scope-all"]'; + this.columnToggleBtn = '[data-test="o2-table-column-toggle-btn"]'; + this.columnTogglePanel = '[data-test="o2-table-column-toggle-panel"]'; + this.columnResetBtn = '[data-test="o2-table-column-toggle-reset-btn"]'; + this.settingsRoot = '[data-test="fleet-settings"]'; + this.settingsTargetVersion = '[data-test="fleet-settings-target-version"]'; + this.settingsAlerts = '[data-test="fleet-settings-alerts"]'; + this.settingsAlertLibrary = '[data-test="fleet-settings-alert-library"]'; + this.errorToast = '[data-test^="o-toast-"][data-test-variant="error"]'; + } + + tab(name) { + return this.page.locator(`[data-test="fleet-manager-tab-${name}"]`); + } + + createButton(tab) { + return this.page.locator(`[data-test="fleet-manager-create-${tab}"]`); + } + + collectorStat(key) { + return this.page.locator(`[data-test="fleet-collectors-stat-${key}"]`); + } + + tableHeader(columnId) { + return this.page.locator(`th[data-test="o2-table-th-${columnId}"]`); + } + + columnToggleCheckbox(columnId) { + return this.page.locator(`[data-test="o2-table-column-toggle-item-${columnId}"] button[role="checkbox"]`); + } + + columnToggleItem(columnId) { + return this.page.locator(`[data-test="o2-table-column-toggle-item-${columnId}"]`); + } + + // Fail loudly, never skip: these specs run only on an enterprise build with fleet management on. + async assertEnabled() { + const base = process.env.ZO_BASE_URL.replace(/\/$/, ''); + // The unauthenticated /config is the login bootstrap and has no fleet_enabled; the org config does. + const config = await this.page.request.get(`${base}/api/${getOrgIdentifier()}/config`, { headers: getAuthHeaders() }); + const fleetEnabled = config.ok() ? (await config.json())?.fleet_enabled : undefined; + const list = await this.page.request.get(`${base}/api/${getOrgIdentifier()}/fleet/fleets?offset=0&limit=1`, { + headers: getAuthHeaders(), + }); + if (fleetEnabled !== true || list.status() !== 200) { + throw new Error( + `Fleet management is NOT available on ${base} (/config fleet_enabled=${fleetEnabled}, GET /fleet/fleets -> ${list.status()}). ` + + 'These specs are enterprise-only and must never be skipped: run them on an enterprise build with O2_FLEET_ENABLED=true.', + ); + } + } + + async gotoFleet(query = {}) { + const params = new URLSearchParams({ org_identifier: getOrgIdentifier(), ...query }); + await this.page.goto(`${process.env.ZO_BASE_URL}/web/fleet?${params}`, { waitUntil: 'domcontentloaded' }); + await expect(this.page.locator(this.managerPage)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async openFromNav() { + const tile = this.page.locator(this.navDataTile); + const item = this.page.locator(this.navFleetItem); + await expect(tile).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + // The flyout opens on hover after a delay and closes on scroll, so open-and-reveal retries as a unit. + await expect(async () => { + await this.page.mouse.move(0, 0); + await tile.hover(); + await expect(item).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: PAGE_TIMEOUT_MS }); + await item.click({ force: true }); + await expect(this.page.locator(this.managerPage)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async clickCreate(tab) { + await this.createButton(tab).click(); + } + + async clickTab(name) { + await this.tab(name).click(); + await this.expectTabActive(name); + } + + async expectTabActive(name) { + await expect(this.tab(name)).toHaveAttribute('data-state', 'active', { timeout: PAGE_TIMEOUT_MS }); + } + + async expectOnFleetRoute(query = {}) { + await expect + .poll(() => { + const url = new URL(this.page.url()); + if (!url.pathname.replace(/\/+$/, '').endsWith('/fleet')) return `path ${url.pathname}`; + for (const [k, v] of Object.entries(query)) { + if (url.searchParams.get(k) !== v) return `${k}=${url.searchParams.get(k)}`; + } + return 'ok'; + }, { timeout: PAGE_TIMEOUT_MS, message: `URL never matched /web/fleet ${JSON.stringify(query)}` }) + .toBe('ok'); + } + + async expectQueryParam(key, value) { + await expect.poll(() => new URL(this.page.url()).searchParams.get(key), { timeout: PAGE_TIMEOUT_MS }).toBe(value); + } + + async goBackAndExpectLeftFleet() { + await this.page.goBack({ waitUntil: 'domcontentloaded' }); + await expect + .poll(() => new URL(this.page.url()).pathname, { timeout: PAGE_TIMEOUT_MS }) + .not.toMatch(/\/fleet\/?$/); + } + + async expectOverviewVisible() { + await this.expectTabActive('overview'); + await expect(this.page.locator(this.flowOverview)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectOverviewNoFleetsWithoutErrorBanner() { + await expect(this.page.locator(this.flowNoFleets)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.flowError)).toHaveCount(0); + } + + async expectFleetNoAccess(text) { + const panel = this.page.locator(this.fleetsNoAccess); + await expect(panel).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + if (text) await expect(panel).toContainText(text); + } + + async expectCollectorsForbidden() { + await expect(this.page.locator(this.fleetsArea).locator(this.tableForbidden)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectCollectorsNotForbidden() { + await expect(this.page.locator(this.fleetsArea).locator(this.tableForbidden)).toHaveCount(0); + } + + async expectFleetNotFound(text) { + const panel = this.page.locator(this.fleetsNotFound); + await expect(panel).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(panel).toContainText(text); + } + + async expectFleetPanelHidden() { + await expect(this.page.locator(this.fleetsPanel)).toHaveCount(0); + } + + async expectAllFleetsScope() { + await expect(this.page.locator(this.collectorsScopeAll)).toHaveAttribute('data-state', 'on', { timeout: PAGE_TIMEOUT_MS }); + await expect(this.tableHeader('fleet')).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectStatPressed(key) { + await expect(this.collectorStat(key)).toHaveAttribute('aria-pressed', 'true', { timeout: PAGE_TIMEOUT_MS }); + } + + async openColumnMenu() { + const panel = this.page.locator(this.columnTogglePanel); + await expect(async () => { + if (!(await panel.isVisible())) await this.page.locator(this.columnToggleBtn).first().click(); + await expect(panel).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectColumnChecked(columnId, checked) { + await this.openColumnMenu(); + await expect(this.columnToggleCheckbox(columnId)).toHaveAttribute('data-state', checked ? 'checked' : 'unchecked'); + } + + async toggleColumn(columnId) { + await this.openColumnMenu(); + await this.columnToggleItem(columnId).click(); + } + + async resetColumns() { + await this.openColumnMenu(); + await this.page.locator(this.columnResetBtn).click(); + } + + async expectColumnShown(columnId, shown) { + if (shown) await expect(this.tableHeader(columnId)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + else await expect(this.tableHeader(columnId)).toHaveCount(0); + } + + async expectSettingsWithoutFleetAlerts(removedTexts) { + await expect(this.page.locator(this.settingsTargetVersion)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.settingsAlerts)).toHaveCount(0); + await expect(this.page.locator(this.settingsAlertLibrary)).toHaveCount(0); + const settings = this.page.locator(this.settingsRoot); + for (const text of removedTexts) await expect(settings).not.toContainText(text); + } + + async expectErrorToast(text) { + await expect(this.page.locator(this.errorToast).filter({ hasText: text }).first()).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + testLogger.info('Error toast shown', { text }); + } + + async expectAnyErrorToast() { + await expect(this.page.locator(this.errorToast).first()).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } +} + +module.exports = FleetManagerPage; diff --git a/tests/ui-testing/pages/fleetPages/fleetRolloutPage.js b/tests/ui-testing/pages/fleetPages/fleetRolloutPage.js new file mode 100644 index 0000000000..a533721291 --- /dev/null +++ b/tests/ui-testing/pages/fleetPages/fleetRolloutPage.js @@ -0,0 +1,71 @@ +const { expect } = require('@playwright/test'); + +const PAGE_TIMEOUT_MS = 30000; + +class FleetRolloutPage { + constructor(page) { + this.page = page; + this.dialog = '[data-test="fleet-new-rollout-dialog"]'; + this.fleetTrigger = '[data-test="fleet-new-rollout-dialog-fleet-trigger"]'; + this.fleetError = '[data-test="fleet-new-rollout-dialog-fleet-error"]'; + this.start = '[data-test="fleet-new-rollout-dialog-start"]'; + // The start and batching items have no data-test; reka renders each item's value on its button. + this.startAt = '[data-test="fleet-new-rollout-dialog-start"] button[value="at"]'; + this.firstField = '[data-test="fleet-new-rollout-dialog-first-field"]'; + this.firstError = '[data-test="fleet-new-rollout-dialog-first-error"]'; + this.initialField = '[data-test="fleet-new-rollout-dialog-initial-field"]'; + this.maxField = '[data-test="fleet-new-rollout-dialog-max-field"]'; + this.maxError = '[data-test="fleet-new-rollout-dialog-max-error"]'; + this.primaryBtn = '[data-test="o-dialog-primary-btn"]'; + } + + batchingItem(kind) { + return this.page.locator(`[data-test="fleet-new-rollout-dialog-batching"] button[value="${kind}"]`); + } + + async expectReady() { + await expect(this.page.locator(this.dialog)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.start)).toBeVisible({ timeout: PAGE_TIMEOUT_MS }); + } + + async expectNoFleetPicked() { + await expect(this.page.locator(this.fleetTrigger)).toHaveAttribute('data-test-selected-value', ''); + } + + async submit() { + await this.page.locator(this.dialog).locator(this.primaryBtn).click(); + } + + async expectFleetError(text) { + await expect(this.page.locator(this.fleetError)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS }); + await expect(this.page.locator(this.dialog)).toBeVisible(); + } + + async expectScheduledDisabled() { + await expect(this.page.locator(this.startAt)).toBeDisabled({ timeout: PAGE_TIMEOUT_MS }); + } + + async chooseBatching(kind) { + if ((await this.batchingItem(kind).getAttribute('data-state')) !== 'on') await this.batchingItem(kind).click(); + await expect(this.batchingItem(kind)).toHaveAttribute('data-state', 'on'); + } + + async setCanaryFirst(value) { + await this.page.locator(this.firstField).fill(String(value)); + } + + async setProgressive(initial, max) { + await this.page.locator(this.initialField).fill(String(initial)); + await this.page.locator(this.maxField).fill(String(max)); + } + + async expectFirstError(text) { + await expect(this.page.locator(this.firstError)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS }); + } + + async expectMaxError(text) { + await expect(this.page.locator(this.maxError)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS }); + } +} + +module.exports = FleetRolloutPage; diff --git a/tests/ui-testing/pages/fleetPages/fleetSeed.js b/tests/ui-testing/pages/fleetPages/fleetSeed.js new file mode 100644 index 0000000000..554f6700ce --- /dev/null +++ b/tests/ui-testing/pages/fleetPages/fleetSeed.js @@ -0,0 +1,392 @@ +const crypto = require('crypto'); +const { expect } = require('@playwright/test'); +const testLogger = require('../../playwright-tests/utils/test-logger.js'); +const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js'); + +const FLEET_PREFIX = 'fl_auto_'; +const CONFIG_PREFIX = 'fl-auto-'; +const LIST_PAGE = 'offset=0&limit=1000'; + +const FLEET_YAML = { + seed: 'service:\n pipelines:\n logs: {}\n', + publishable: [ + 'receivers:', + ' otlp:', + ' protocols:', + ' http:', + ' endpoint: "0.0.0.0:4318"', + 'exporters:', + ' otlphttp:', + ' endpoint: "https://example.com"', + 'service:', + ' pipelines:', + ' logs:', + ' receivers: [otlp]', + ' exporters: [otlphttp]', + '', + ].join('\n'), + broken: 'receivers:\n otlp: [unclosed\n', + dottedKey: [ + 'processors:', + ' resourcedetection:', + ' detectors:', + ' - system', + ' system:', + ' resource_attributes:', + ' host.id:', + ' enabled: true', + 'exporters:', + ' debug:', + ' verbosity: basic', + 'service:', + ' pipelines:', + ' logs:', + ' processors: [resourcedetection]', + ' exporters: [debug]', + '', + ].join('\n'), + quotedNumber: [ + 'exporters:', + ' debug:', + ' verbosity: "123"', + 'service:', + ' pipelines:', + ' logs:', + ' exporters: [debug]', + '', + ].join('\n'), + paletteResult: [ + 'receivers:', + ' otlp:', + ' protocols:', + ' grpc:', + ' endpoint: "0.0.0.0:4317"', + ' http:', + ' endpoint: "0.0.0.0:4318"', + 'processors:', + ' batch:', + ' timeout: 5s', + ' send_batch_size: 8192', + ' memory_limiter:', + ' check_interval: 1s', + ' limit_mib: 400', + ' spike_limit_mib: 100', + 'exporters:', + ' otlphttp/openobserve:', + ' endpoint: "${env:O2_ENDPOINT}"', + ' headers:', + ' Authorization: "${env:O2_TOKEN}"', + ' stream-name: default', + 'service:', + ' pipelines:', + ' logs:', + ' receivers: [otlp]', + ' processors: [memory_limiter, batch]', + ' exporters: [otlphttp/openobserve]', + '', + ].join('\n'), +}; + +class FleetSeed { + constructor(page) { + this.page = page; + this.run = crypto.randomUUID().replace(/-/g, '').slice(0, 8); + this.fleetPrefix = `${FLEET_PREFIX}${this.run}_`; + this.configPrefix = `${CONFIG_PREFIX}${this.run}-`; + this.loginUserField = '[data-test="login-user-id-field"]'; + this.loginPasswordField = '[data-test="login-password-field"]'; + this.loginSubmit = '[data-test="login-sign-in"]'; + this.loginInternalUser = '[data-test="login-as-internal-user"]'; + this.appShell = '[data-test="navbar-main-nav"]'; + } + + // beforeAll and afterAll run on different raw pages, so the run prefix must outlive the first one. + setPage(page) { + this.page = page; + } + + fleetName(suffix) { + return `${this.fleetPrefix}${suffix}`; + } + + configId(suffix) { + return `${this.configPrefix}${suffix}`; + } + + orgId() { + return getOrgIdentifier(); + } + + apiUrl(path) { + return `${process.env.ZO_BASE_URL.replace(/\/$/, '')}/api/${getOrgIdentifier()}${path}`; + } + + async call(method, path, data) { + const resp = await this.page.request.fetch(this.apiUrl(path), { + method, + headers: getAuthHeaders(), + ...(data === undefined ? {} : { data }), + }); + const text = await resp.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: resp.status(), body }; + } + + async ok(method, path, data, what) { + const res = await this.call(method, path, data); + if (res.status >= 400) { + throw new Error(`${what} failed: ${method} ${path} -> ${res.status} ${JSON.stringify(res.body).slice(0, 300)}`); + } + return res.body; + } + + async createFleet(suffix) { + const name = this.fleetName(suffix); + const body = await this.ok('POST', '/fleet/fleets', { name }, `create fleet ${name}`); + if (!body?.id) throw new Error(`create fleet ${name}: no id in ${JSON.stringify(body)}`); + testLogger.info('Seeded fleet', { id: body.id, name }); + return { id: body.id, name }; + } + + async createToken(fleetId, suffix) { + const name = this.fleetName(suffix); + const body = await this.ok( + 'POST', + '/fleet/tokens', + { name, expires_in_days: null, max_uses: null, fleet: fleetId }, + `create token ${name}`, + ); + if (!/^o2ft_/.test(body?.token ?? '')) throw new Error(`create token ${name}: no o2ft_ value returned`); + return { id: body.id, name, token: body.token }; + } + + async createConfig(suffix, yaml) { + const id = this.configId(suffix); + await this.ok('POST', '/fleet/configs', { id, description: 'fleet automation', yaml }, `create config ${id}`); + return id; + } + + async publishConfig(id, yaml, message = 'fleet automation') { + const body = await this.ok('POST', `/fleet/configs/${id}/versions`, { yaml, message }, `publish ${id}`); + return body.version; + } + + async getConfig(id) { + return await this.ok('GET', `/fleet/configs/${id}`, undefined, `get config ${id}`); + } + + async setDesired(fleetId, config, version) { + const body = await this.ok( + 'PUT', + `/fleet/fleets/${fleetId}/desired`, + { + config, + version, + start: { kind: 'now' }, + batching: { kind: 'one' }, + verify: false, + auto_rollback: false, + }, + `deploy ${config} v${version} to ${fleetId}`, + ); + return { id: body.id }; + } + + async waitForRolloutStatus(rolloutId, status) { + await expect + .poll(async () => (await this.call('GET', `/fleet/rollouts/${rolloutId}`)).body?.status, { + timeout: 30000, + message: `rollout ${rolloutId} never reached ${status}`, + }) + .toBe(status); + } + + async rolloutsForConfig(configId) { + const body = await this.ok('GET', `/fleet/rollouts?${LIST_PAGE}`, undefined, 'list rollouts'); + return (body?.items ?? []).filter((r) => r.config === configId); + } + + async listFleets() { + const body = await this.ok('GET', `/fleet/fleets?${LIST_PAGE}`, undefined, 'list fleets'); + return body?.items ?? []; + } + + async expectNoFleetNamed(name) { + const names = (await this.listFleets()).map((f) => f.name); + expect(names, `fleet ${name} exists although its create was refused`).not.toContain(name); + } + + async targetCollectorVersion() { + const body = await this.ok('GET', '/fleet/settings', undefined, 'get fleet settings'); + return body?.target_collector_version || null; + } + + async ingestionBaseUrl() { + const config = await this.ok('GET', '/config', undefined, 'get org config'); + return config?.ingestion_url || null; + } + + async assertRbacEnabled() { + const { status } = await this.call('GET', '/roles'); + if (status >= 400) { + throw new Error(`RBAC is off on ${process.env.ZO_BASE_URL} (GET /roles -> ${status}); the Fleet-RBAC shard must start OpenFGA.`); + } + } + + async createRole(suffix, grants) { + const role = this.fleetName(suffix); + await this.ok('POST', '/roles', { role }, `create role ${role}`); + if (grants.length) { + await this.ok( + 'PUT', + `/roles/${role}`, + { add: grants, remove: [], add_users: [], remove_users: [] }, + `grant ${role}`, + ); + } + return role; + } + + async createMember(suffix, role = null) { + const email = `${this.fleetName(suffix)}_${crypto.randomUUID().slice(0, 8)}@example.com`; + const password = `Fl#9${crypto.randomBytes(12).toString('base64url')}aZ`; + await this.ok( + 'POST', + '/users', + { email, password, first_name: 'Automation', last_name: 'TestUser', role: 'user' }, + `create member ${email}`, + ); + if (role) { + await this.ok( + 'PUT', + `/roles/${role}`, + { add: [], remove: [], add_users: [email], remove_users: [] }, + `assign ${role} to ${email}`, + ); + } + return { email, password }; + } + + fleetGrant(object, permission) { + return { object: `fleets:${object}`, permission }; + } + + orgGrant(type, permission) { + return { object: `${type}:_all_${getOrgIdentifier()}`, permission }; + } + + async loginAsMember(browser, member) { + const context = await browser.newContext({ + viewport: { width: 1500, height: 1024 }, + permissions: ['clipboard-read', 'clipboard-write'], + }); + const page = await context.newPage(); + await page.goto(`${process.env.ZO_BASE_URL}/web/?org_identifier=${getOrgIdentifier()}`, { + waitUntil: 'domcontentloaded', + }); + const userField = page.locator(this.loginUserField); + const internal = page.locator(this.loginInternalUser).first(); + await expect(userField.or(internal)).toBeVisible({ timeout: 30000 }); + if (await internal.isVisible()) await internal.click(); + await userField.fill(member.email); + await page.locator(this.loginPasswordField).fill(member.password); + await page.locator(this.loginSubmit).click(); + await expect(page.locator(this.appShell)).toBeVisible({ timeout: 30000 }); + testLogger.info('Signed in as fleet member', { email: member.email }); + return { context, page }; + } + + // Grants reach OpenFGA asynchronously, so a member's first read must see them before the UI is judged. + async waitForMemberStatus(memberPage, path, status) { + await expect + .poll( + async () => + ( + await memberPage.request.fetch(this.apiUrl(path), { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + ).status(), + { timeout: 30000, message: `member GET ${path} never answered ${status}` }, + ) + .toBe(status); + } + + async cleanup() { + const removed = { tokens: 0, fleets: 0, configs: 0, roles: 0, users: 0 }; + const mine = async (path, key) => { + const res = await this.call('GET', path); + if (res.status >= 400) return []; + const items = Array.isArray(res.body) ? res.body : (res.body?.items ?? res.body?.data ?? []); + return items.filter((x) => typeof key(x) === 'string'); + }; + const fleets = (await mine(`/fleet/fleets?${LIST_PAGE}`, (f) => f?.name)).filter((f) => + f.name.startsWith(this.fleetPrefix), + ); + const fleetIds = new Set(fleets.map((f) => f.id)); + for (const tok of await mine(`/fleet/tokens?${LIST_PAGE}`, (t) => t?.id)) { + if (fleetIds.has(tok.fleet?.id) && !tok.revoked) { + await this.call('POST', `/fleet/tokens/${tok.id}/revoke`); + removed.tokens += 1; + } + } + for (const f of fleets) { + await this.call('DELETE', `/fleet/fleets/${f.id}`); + removed.fleets += 1; + } + for (const c of await mine(`/fleet/configs?${LIST_PAGE}`, (c) => c?.id)) { + if (c.id.startsWith(this.configPrefix)) { + await this.call('DELETE', `/fleet/configs/${c.id}`); + removed.configs += 1; + } + } + for (const u of await mine('/users', (u) => u?.email)) { + if (u.email.startsWith(this.fleetPrefix)) { + await this.call('DELETE', `/users/${u.email}`); + removed.users += 1; + } + } + const roles = await this.call('GET', '/roles'); + for (const r of roles.status < 400 && Array.isArray(roles.body) ? roles.body : []) { + if (typeof r === 'string' && r.startsWith(this.fleetPrefix)) { + await this.call('DELETE', `/roles/${r}`); + removed.roles += 1; + } + } + testLogger.info('Fleet teardown', { run: this.run, ...removed }); + return removed; + } + + // Anything still carrying this run's prefix after cleanup() is a leak; a failed list read fails loudly. + async survivors() { + const left = []; + for (const f of await this.listFleets()) { + if (f.name?.startsWith(this.fleetPrefix)) left.push(`fleet ${f.name}`); + } + const configs = await this.ok('GET', `/fleet/configs?${LIST_PAGE}`, undefined, 'list configs'); + for (const c of configs?.items ?? []) { + if (c.id?.startsWith(this.configPrefix)) left.push(`config ${c.id}`); + } + const roles = await this.call('GET', '/roles'); + if (roles.status < 400) { + for (const r of Array.isArray(roles.body) ? roles.body : []) { + if (typeof r === 'string' && r.startsWith(this.fleetPrefix)) left.push(`role ${r}`); + } + } + const users = await this.ok('GET', '/users', undefined, 'list users'); + for (const u of users?.data ?? []) { + if (u?.email?.startsWith(this.fleetPrefix)) left.push(`user ${u.email}`); + } + return left; + } +} + +module.exports = FleetSeed; +module.exports.FLEET_YAML = FLEET_YAML; +module.exports.FLEET_PREFIX = FLEET_PREFIX; +module.exports.CONFIG_PREFIX = CONFIG_PREFIX; diff --git a/tests/ui-testing/pages/iamPages/rolesPage.js b/tests/ui-testing/pages/iamPages/rolesPage.js index b7c3496554..f6ef415aaf 100644 --- a/tests/ui-testing/pages/iamPages/rolesPage.js +++ b/tests/ui-testing/pages/iamPages/rolesPage.js @@ -193,6 +193,17 @@ export class RolesPage { return [...new Set(slugs.map((s) => s.replace(RAIL_ITEM, '')))]; } + /** Rail module key → its label, read without the granted-count badge. */ + async railModuleLabels() { + const rows = await this.page + .locator(`[data-test^="${RAIL_ITEM}"]`) + .evaluateAll((els) => els.map((e) => [ + e.getAttribute('data-test'), + (e.querySelector('.o-tab__label')?.textContent ?? '').trim(), + ])); + return Object.fromEntries(rows.map(([slug, label]) => [slug.replace(RAIL_ITEM, ''), label])); + } + /** * Resource keys of the rows the pane is CURRENTLY showing — after any search or * filter. On the All Modules view a row is a whole module, so this is the set one diff --git a/tests/ui-testing/pages/page-manager.js b/tests/ui-testing/pages/page-manager.js index f349553a97..19ab633e88 100644 --- a/tests/ui-testing/pages/page-manager.js +++ b/tests/ui-testing/pages/page-manager.js @@ -145,6 +145,12 @@ const SchemaLoadPage = require("./generalPages/schemaLoadPage.js"); const APICleanup = require("./apiCleanup.js"); const WorkflowsPage = require("./workflowsPages/workflowsPage.js"); const WorkflowFoldersPage = require("./workflowsPages/workflowFoldersPage.js"); +const FleetManagerPage = require("./fleetPages/fleetManagerPage.js"); +const FleetFleetsPage = require("./fleetPages/fleetFleetsPage.js"); +const FleetInstallPage = require("./fleetPages/fleetInstallPage.js"); +const FleetConfigPage = require("./fleetPages/fleetConfigPage.js"); +const FleetRolloutPage = require("./fleetPages/fleetRolloutPage.js"); +const FleetSeed = require("./fleetPages/fleetSeed.js"); // ===== LOGS, REPORTS, STREAMS, PIPELINES ADDITIONAL PAGE OBJECTS ===== import { LogsQueryPage } from "./logsPages/logsQueryPage.js"; @@ -261,6 +267,14 @@ class PageManager { this.workflowsPage = new WorkflowsPage(page); this.workflowFoldersPage = new WorkflowFoldersPage(page); + // ===== FLEET MANAGEMENT PAGE OBJECTS ===== + this.fleetManagerPage = new FleetManagerPage(page); + this.fleetFleetsPage = new FleetFleetsPage(page); + this.fleetInstallPage = new FleetInstallPage(page); + this.fleetConfigPage = new FleetConfigPage(page); + this.fleetRolloutPage = new FleetRolloutPage(page); + this.fleetSeed = new FleetSeed(page); + // ===== SANITY SPEC ADDITIONAL PAGE OBJECTS ===== this.logsPage = new LogsPage(page); this.logsToolbarPinsPage = new LogsToolbarPinsPage(page); diff --git a/tests/ui-testing/playwright-tests/Fleet/fleet-access.spec.js b/tests/ui-testing/playwright-tests/Fleet/fleet-access.spec.js new file mode 100644 index 0000000000..fcfe500122 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Fleet/fleet-access.spec.js @@ -0,0 +1,161 @@ +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); + +test.describe('Fleet management access', { tag: ['@fleet', '@enterprise', '@all'] }, () => { + // Serial: the roles, users and fleets are made once in beforeAll and shared, as in IAM/iam-roles-enforcement.spec.js. + test.describe.configure({ mode: 'serial' }); + + let pm; + let seed; + let fleetA; + let fleetB; + const members = {}; + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + try { + const setup = new PageManager(page); + await setup.fleetManagerPage.assertEnabled(); + seed = setup.fleetSeed; + await seed.assertRbacEnabled(); + fleetA = await seed.createFleet('a'); + fleetB = await seed.createFleet('b'); + const reader = await seed.createRole('reader', [ + seed.orgGrant('fleets', 'AllowList'), + seed.orgGrant('fleets', 'AllowGet'), + seed.orgGrant('fleet_configs', 'AllowGet'), + seed.orgGrant('fleet_management', 'AllowGet'), + ]); + const aWriter = await seed.createRole('a_writer', [seed.fleetGrant(fleetA.id, 'AllowAll')]); + const listOnly = await seed.createRole('list_only', [seed.orgGrant('fleets', 'AllowList')]); + members.none = await seed.createMember('none'); + members.reader = await seed.createMember('reader', reader); + members.aWriter = await seed.createMember('a_writer', aWriter); + members.listOnly = await seed.createMember('list_only', listOnly); + testLogger.info('Fleet access fixtures ready', { fleetA: fleetA.id, fleetB: fleetB.id }); + } finally { + await page.close(); + } + }); + + test.afterAll(async ({ browser }) => { + if (!seed) return; + const page = await browser.newPage(); + try { + seed.setPage(page); + await seed.cleanup(); + expect(await seed.survivors()).toEqual([]); + } finally { + await page.close(); + } + }); + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + // The beforeAll page that seeded the fixtures is closed, so seed API calls need this test's live page. + seed.setPage(page); + }); + + test('FL-RBAC-02 + R-16105a · a member without fleet grants gets the no-access states and no pipelines error banner', { + tag: ['@fleetAccess', '@regression', '@P0'], + }, async ({ browser }) => { + const member = await seed.loginAsMember(browser, members.none); + try { + const mpm = new PageManager(member.page); + await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets' }); + await mpm.fleetManagerPage.expectFleetNoAccess(); + await mpm.fleetManagerPage.expectCollectorsForbidden(); + + await mpm.fleetManagerPage.clickTab('overview'); + await mpm.fleetManagerPage.expectOverviewNoFleetsWithoutErrorBanner(); + } finally { + await member.context.close(); + } + }); + + test('FL-RBAC-03 + R-16105b · a reader clicking New fleet gets "You don\'t have permission to do this" and nothing is created', { + tag: ['@fleetAccess', '@regression', '@P0'], + }, async ({ browser }) => { + const member = await seed.loginAsMember(browser, members.reader); + try { + await seed.waitForMemberStatus(member.page, `/fleet/fleets/${fleetA.id}`, 200); + const mpm = new PageManager(member.page); + const refused = seed.fleetName('refused'); + await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id }); + await mpm.fleetFleetsPage.expectPanelFor(fleetA.name); + await mpm.fleetManagerPage.clickCreate('fleets'); + await mpm.fleetFleetsPage.submitNewFleet(refused); + await mpm.fleetManagerPage.expectErrorToast("You don't have permission to do this"); + await seed.expectNoFleetNamed(refused); + } finally { + await member.context.close(); + } + }); + + test('R-3232-ui · a writer of fleet A only can open the Fleets tab and read fleet A, not fleet B', { + tag: ['@fleetAccess', '@regression', '@P0'], + }, async ({ browser }) => { + const member = await seed.loginAsMember(browser, members.aWriter); + try { + await seed.waitForMemberStatus(member.page, `/fleet/fleets/${fleetA.id}`, 200); + const mpm = new PageManager(member.page); + await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id }); + await mpm.fleetFleetsPage.expectPanelFor(fleetA.name); + await mpm.fleetManagerPage.expectCollectorsNotForbidden(); + await mpm.fleetFleetsPage.expectRailItemReadable(fleetA.id, true); + await mpm.fleetFleetsPage.expectRailItemReadable(fleetB.id, false); + + await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetB.id }); + await mpm.fleetManagerPage.expectFleetNoAccess(); + } finally { + await member.context.close(); + } + }); + + test('FL-RBAC-08 · a member without GET on a fleet sees "You can see this fleet, but not its collectors or versions"', { + tag: ['@fleetAccess', '@P2'], + }, async ({ browser }) => { + // With O2_OPENFGA_LIST_ONLY_PERMITTED off (this shard), LIST adds nothing over no grant; this proves the no-GET panel only. + const member = await seed.loginAsMember(browser, members.listOnly); + try { + const mpm = new PageManager(member.page); + await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id }); + await mpm.fleetManagerPage.expectFleetNoAccess('You can see this fleet, but not its collectors or versions'); + await mpm.fleetFleetsPage.expectRailItemReadable(fleetA.id, false); + } finally { + await member.context.close(); + } + }); + + test('R-16104c · the Settings tab has no "Alerts for fleets" section', { + tag: ['@fleetAccess', '@regression', '@P2'], + }, async () => { + await pm.fleetManagerPage.gotoFleet({ tab: 'settings' }); + await pm.fleetManagerPage.expectTabActive('settings'); + await pm.fleetManagerPage.expectSettingsWithoutFleetAlerts(['Alerts for fleets', 'Open the alert library']); + }); + + test('R-16105c · collectors "Reset to default" keeps Architecture and Last connected from hidden', { + tag: ['@fleetAccess', '@regression', '@P3'], + }, async () => { + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id }); + await pm.fleetFleetsPage.expectPanelFor(fleetA.name); + await pm.fleetManagerPage.expectColumnChecked('arch', false); + await pm.fleetManagerPage.expectColumnChecked('lastConnectedFrom', false); + + await pm.fleetManagerPage.toggleColumn('os'); + await pm.fleetManagerPage.expectColumnChecked('os', false); + await pm.fleetManagerPage.expectColumnShown('os', false); + + await pm.fleetManagerPage.resetColumns(); + await pm.fleetManagerPage.expectColumnChecked('os', true); + await pm.fleetManagerPage.expectColumnChecked('arch', false); + await pm.fleetManagerPage.expectColumnChecked('lastConnectedFrom', false); + await pm.fleetManagerPage.expectColumnShown('os', true); + await pm.fleetManagerPage.expectColumnShown('arch', false); + await pm.fleetManagerPage.expectColumnShown('lastConnectedFrom', false); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Fleet/fleet-config-deploy.spec.js b/tests/ui-testing/playwright-tests/Fleet/fleet-config-deploy.spec.js new file mode 100644 index 0000000000..163b9ba2dc --- /dev/null +++ b/tests/ui-testing/playwright-tests/Fleet/fleet-config-deploy.spec.js @@ -0,0 +1,174 @@ +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const { FLEET_YAML } = require('../../pages/fleetPages/fleetSeed.js'); + +const DOTTED_KEY = 'system.resource_attributes."host.id".enabled'; + +test.describe('Fleet management configurations and deploy', { tag: ['@fleet', '@enterprise', '@all'] }, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + // Cleared first so a failed navigation never leaves afterEach the previous test's closed page. + pm = undefined; + await navigateToBase(page); + pm = new PageManager(page); + await pm.fleetManagerPage.assertEnabled(); + }); + + test.afterEach(async () => { + if (!pm) return; + await pm.fleetSeed.cleanup(); + expect(await pm.fleetSeed.survivors()).toEqual([]); + }); + + test('FL-CFG-03 + FL-CFG-08 · palette OTLP, Batch, OpenObserve and Memory limiter wire a pipeline with memory_limiter first and env references', { + tag: ['@fleetConfig', '@smoke', '@P0'], + }, async () => { + const config = await pm.fleetSeed.createConfig('palette', FLEET_YAML.seed); + await pm.fleetConfigPage.goto(config); + await pm.fleetConfigPage.expectVisualMode(); + + await pm.fleetConfigPage.addFromPalette('receiver', 'otlp', 'logs|receiver:otlp'); + await pm.fleetConfigPage.addFromPalette('processor', 'batch', 'logs|processor:batch'); + await pm.fleetConfigPage.addFromPalette('exporter', 'openobserve', 'logs|exporter:otlphttp/openobserve'); + await pm.fleetConfigPage.addFromPalette('processor', 'memory_limiter', 'logs|processor:memory_limiter'); + + await pm.fleetConfigPage.switchToYaml(); + await pm.fleetConfigPage.expectYaml(FLEET_YAML.paletteResult); + const yaml = await pm.fleetConfigPage.readYaml(); + expect(yaml).toContain('processors: [memory_limiter, batch]'); + expect(yaml).toContain('endpoint: "${env:O2_ENDPOINT}"'); + expect(yaml).toContain('Authorization: "${env:O2_TOKEN}"'); + }); + + test('FL-CFG-04 + FL-CFG-05 · a YAML edit round-trips through Visual byte for byte and a dotted key stays one key', { + tag: ['@fleetConfig', '@regression', '@P1'], + }, async () => { + const config = await pm.fleetSeed.createConfig('roundtrip', FLEET_YAML.seed); + await pm.fleetConfigPage.goto(config); + await pm.fleetConfigPage.switchToYaml(); + await pm.fleetConfigPage.setYaml(FLEET_YAML.dottedKey); + + await pm.fleetConfigPage.switchToVisual(); + await pm.fleetConfigPage.selectNode('logs|processor:resourcedetection'); + await pm.fleetConfigPage.expectPropertyValue(DOTTED_KEY, 'true'); + await pm.fleetConfigPage.expectNoProperty('system.resource_attributes.host.id.enabled'); + await pm.fleetConfigPage.setProperty(DOTTED_KEY, 'false'); + + await pm.fleetConfigPage.switchToYaml(); + await pm.fleetConfigPage.expectYaml(FLEET_YAML.dottedKey.replace('enabled: true', 'enabled: false')); + }); + + test('R-16104a · Visual keeps "true", "123" and "null" as strings, and YAML with a quoted number opens in Visual', { + tag: ['@fleetConfig', '@regression', '@P1'], + }, async () => { + const config = await pm.fleetSeed.createConfig('quoted', FLEET_YAML.seed); + await pm.fleetConfigPage.goto(config); + await pm.fleetConfigPage.switchToYaml(); + await pm.fleetConfigPage.setYaml(FLEET_YAML.quotedNumber); + + await pm.fleetConfigPage.switchToVisual(); + await pm.fleetConfigPage.selectNode('logs|exporter:debug'); + await pm.fleetConfigPage.expectPropertyValue('verbosity', '"123"'); + await pm.fleetConfigPage.addSetting('fl_true', '"true"'); + await pm.fleetConfigPage.addSetting('fl_num', '"123"'); + await pm.fleetConfigPage.addSetting('fl_null', '"null"'); + + await pm.fleetConfigPage.switchToYaml(); + await pm.fleetConfigPage.expectYaml( + FLEET_YAML.quotedNumber.replace( + ' verbosity: "123"\n', + ' verbosity: "123"\n fl_true: "true"\n fl_num: "123"\n fl_null: "null"\n', + ), + ); + }); + + test('FL-CFG-09 · invalid YAML validates as "1 error · publish blocked" and Publish is refused', { + tag: ['@fleetConfig', '@smoke', '@P0'], + }, async () => { + const config = await pm.fleetSeed.createConfig('invalid', FLEET_YAML.seed); + await pm.fleetConfigPage.goto(config); + await pm.fleetConfigPage.switchToYaml(); + await pm.fleetConfigPage.setYaml(FLEET_YAML.broken); + await pm.fleetConfigPage.expectVisualUnavailable(); + + await pm.fleetConfigPage.validate(); + await pm.fleetConfigPage.expectValidationChip('1 error · publish blocked'); + + await pm.fleetConfigPage.publish('fleet automation invalid'); + await pm.fleetManagerPage.expectAnyErrorToast(); + await pm.fleetConfigPage.expectModeChip('Editing draft'); + expect((await pm.fleetSeed.getConfig(config)).latest_version).toBe(0); + }); + + test('FL-VER-01 + FL-VER-02 · publishing reads "published v1 · not rolled out"; after v2 Compare shows the diff and is off for v1', { + tag: ['@fleetConfig', '@P1'], + }, async () => { + const config = await pm.fleetSeed.createConfig('versions', FLEET_YAML.publishable); + await pm.fleetConfigPage.goto(config); + await pm.fleetConfigPage.expectModeChip('Editing draft'); + + await pm.fleetConfigPage.publish('first version'); + await pm.fleetConfigPage.expectModeChip('Viewing published v1 · not rolled out'); + await pm.fleetConfigPage.expectSuccessToast('Published v1'); + + await pm.fleetConfigPage.switchToYaml(); + await pm.fleetConfigPage.setYaml(FLEET_YAML.publishable.replace('https://example.com', 'https://example.org')); + await pm.fleetConfigPage.expectModeChip('Editing draft'); + await pm.fleetConfigPage.publish('second version'); + await pm.fleetConfigPage.expectModeChip('Viewing published v2 · not rolled out'); + + await pm.fleetConfigPage.openHistory(); + await pm.fleetConfigPage.expectCompareDisabled(1); + await pm.fleetConfigPage.compare(2); + await pm.fleetConfigPage.expectDiffContains('https://example.org'); + expect((await pm.fleetSeed.getConfig(config)).latest_version).toBe(2); + }); + + test('R-16104b · Deploy from the editor opens with no fleet picked and is blocked with "Pick a fleet"', { + tag: ['@fleetDeploy', '@regression', '@P1'], + }, async () => { + await pm.fleetSeed.createFleet('pick'); + const config = await pm.fleetSeed.createConfig('deploy', FLEET_YAML.publishable); + await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable); + await pm.fleetConfigPage.goto(config); + + await pm.fleetConfigPage.openDeployFromEditor(); + await pm.fleetRolloutPage.expectReady(); + await pm.fleetRolloutPage.expectNoFleetPicked(); + await pm.fleetRolloutPage.submit(); + await pm.fleetRolloutPage.expectFleetError('Pick a fleet'); + expect(await pm.fleetSeed.rolloutsForConfig(config)).toEqual([]); + }); + + test('FL-RO-01 · the deploy dialog disables Scheduled for an empty fleet and refuses counts below 1 and a max below the initial batch', { + tag: ['@fleetDeploy', '@P1'], + }, async () => { + const fleet = await pm.fleetSeed.createFleet('guards'); + const config = await pm.fleetSeed.createConfig('guards', FLEET_YAML.publishable); + const v1 = await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable); + const seeded = await pm.fleetSeed.setDesired(fleet.id, config, v1); + await pm.fleetSeed.waitForRolloutStatus(seeded.id, 'completed'); + // A desired v1 plus a published v2 seeds a valid config and version, so only the batching fields are judged. + await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable.replace('https://example.com', 'https://example.org')); + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id }); + await pm.fleetFleetsPage.openDeploy(); + await pm.fleetRolloutPage.expectReady(); + await pm.fleetRolloutPage.expectScheduledDisabled(); + + await pm.fleetRolloutPage.chooseBatching('canary'); + await pm.fleetRolloutPage.setCanaryFirst(0); + await pm.fleetRolloutPage.submit(); + await pm.fleetRolloutPage.expectFirstError('Enter a whole number of 1 or more'); + + await pm.fleetRolloutPage.chooseBatching('progressive'); + await pm.fleetRolloutPage.setProgressive(5, 2); + await pm.fleetRolloutPage.submit(); + await pm.fleetRolloutPage.expectMaxError('Use at least the initial batch'); + expect((await pm.fleetSeed.rolloutsForConfig(config)).map((r) => r.id)).toEqual([seeded.id]); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Fleet/fleet-install.spec.js b/tests/ui-testing/playwright-tests/Fleet/fleet-install.spec.js new file mode 100644 index 0000000000..8daabb930d --- /dev/null +++ b/tests/ui-testing/playwright-tests/Fleet/fleet-install.spec.js @@ -0,0 +1,151 @@ +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const FleetInstallPage = require('../../pages/fleetPages/fleetInstallPage.js'); + +const PLATFORMS = [ + { id: 'FL-INST-03', label: 'Linux', platform: 'linux' }, + { id: 'FL-INST-04', label: 'macOS', platform: 'macos' }, + { id: 'FL-INST-05', label: 'Windows', platform: 'windows' }, + { id: 'FL-INST-06', label: 'Docker', platform: 'docker' }, + { id: 'FL-INST-07', label: 'Kubernetes agent', platform: 'kubernetes', role: 'agent' }, + { id: 'FL-INST-08', label: 'Kubernetes gateway', platform: 'kubernetes', role: 'gateway' }, +]; + +test.describe('Fleet management Add collector', { tag: ['@fleet', '@enterprise', '@all'] }, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + // Cleared first so a failed navigation never leaves afterEach the previous test's closed page. + pm = undefined; + await navigateToBase(page); + pm = new PageManager(page); + await pm.fleetManagerPage.assertEnabled(); + }); + + test.afterEach(async () => { + if (!pm) return; + await pm.fleetSeed.cleanup(); + expect(await pm.fleetSeed.survivors()).toEqual([]); + }); + + for (const p of PLATFORMS) { + test(`${p.id} + FL-INST-09 + FL-UNI-02 · ${p.label}: every install line over HTTP and WebSocket, uninstall without the token`, { + tag: ['@fleetInstall', '@regression', '@P0'], + }, async () => { + const fleet = await pm.fleetSeed.createFleet(`inst_${p.platform}_${p.role ?? 'host'}`); + const token = await pm.fleetSeed.createToken(fleet.id, 'tok'); + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id }); + await pm.fleetFleetsPage.openAddCollector(); + await pm.fleetInstallPage.expectDialogOpen(); + await pm.fleetInstallPage.expectCommandShown(); + + await pm.fleetInstallPage.selectPlatform(p.platform); + if (p.role) await pm.fleetInstallPage.selectRole(p.role); + const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, token.token)), role: p.role }; + await pm.fleetInstallPage.revealToken(); + + await pm.fleetInstallPage.expectWebsocket(false); + await pm.fleetInstallPage.expectCommandLines( + FleetInstallPage.expectedInstallLines(p.platform, { ...target, http: true }), + ); + + await pm.fleetInstallPage.setWebsocket(true); + await pm.fleetInstallPage.expectCommandLines( + FleetInstallPage.expectedInstallLines(p.platform, { ...target, http: false }), + ); + + await pm.fleetInstallPage.expandUninstall(); + await pm.fleetInstallPage.expectUninstallLines( + FleetInstallPage.expectedUninstallLines(p.platform, p.role), + token.token, + ); + }); + } + + test('R-16042 · a Kubernetes cluster name becomes --set-string clusterName with commas escaped; empty adds no flag', { + tag: ['@fleetInstall', '@regression', '@P2'], + }, async () => { + const fleet = await pm.fleetSeed.createFleet('cluster'); + const token = await pm.fleetSeed.createToken(fleet.id, 'tok'); + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id }); + await pm.fleetFleetsPage.openAddCollector(); + await pm.fleetInstallPage.expectCommandShown(); + await pm.fleetInstallPage.selectPlatform('kubernetes'); + const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, token.token)), http: true, role: 'agent' }; + await pm.fleetInstallPage.revealToken(); + + await pm.fleetInstallPage.setClusterName('prod,us-east'); + const withName = FleetInstallPage.expectedInstallLines('kubernetes', { ...target, clusterName: 'prod,us-east' }); + expect(withName).toContain(" --set-string clusterName='prod\\,us-east' \\"); + await pm.fleetInstallPage.expectCommandLines(withName); + + await pm.fleetInstallPage.setClusterName(''); + const withoutName = FleetInstallPage.expectedInstallLines('kubernetes', target); + expect(withoutName.some((line) => line.includes('clusterName'))).toBe(false); + await pm.fleetInstallPage.expectCommandLines(withoutName); + }); + + test('FL-INST-10 · the token stays masked until Reveal, and Copy gives the full command with the real token', { + tag: ['@fleetInstall', '@P1'], + }, async () => { + const fleet = await pm.fleetSeed.createFleet('mask'); + const token = await pm.fleetSeed.createToken(fleet.id, 'tok'); + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id }); + await pm.fleetFleetsPage.openAddCollector(); + await pm.fleetInstallPage.expectCommandShown(); + const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, token.token)), http: true }; + + await pm.fleetInstallPage.expectCommandMasked(token.token); + const copied = await pm.fleetInstallPage.copyCommand(); + expect(copied.split('\n')).toEqual(FleetInstallPage.expectedInstallLines('linux', target)); + + await pm.fleetInstallPage.revealToken(); + await pm.fleetInstallPage.expectCommandLines(FleetInstallPage.expectedInstallLines('linux', target)); + }); + + test('FL-INST-01 + R-16103 · with no token the inline form creates one, shows the command and polls collectors again', { + tag: ['@fleetInstall', '@regression', '@P0'], + }, async () => { + const fleet = await pm.fleetSeed.createFleet('notoken'); + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id }); + await pm.fleetFleetsPage.openAddCollector(); + await pm.fleetInstallPage.expectNoTokenState(); + + const created = await pm.fleetInstallPage.createInlineTokenAndAwaitCollectorsPoll(pm.fleetSeed.fleetName('inline')); + expect(created.token).toMatch(/^o2ft_/); + expect(created.fleet?.id).toBe(fleet.id); + + await pm.fleetInstallPage.expectCommandShown(); + await pm.fleetInstallPage.expectCopyOnceAndWaiting(); + await pm.fleetInstallPage.expectCommandMasked(created.token); + }); + + test('FL-INST-02 + FL-INST-11 · the token select lists "{name} (no collectors)" newest first, says which fleet it joins, and --url is the ingestion URL', { + tag: ['@fleetInstall', '@P1'], + }, async () => { + const fleet = await pm.fleetSeed.createFleet('pick'); + const older = await pm.fleetSeed.createToken(fleet.id, 'older'); + const newer = await pm.fleetSeed.createToken(fleet.id, 'newer'); + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id }); + await pm.fleetFleetsPage.openAddCollector(); + await pm.fleetInstallPage.expectCommandShown(); + + await pm.fleetInstallPage.expectSelectedToken(`${newer.name} (no collectors)`); + expect(await pm.fleetInstallPage.tokenOptionLabels()).toEqual([ + `${newer.name} (no collectors)`, + `${older.name} (no collectors)`, + ]); + await pm.fleetInstallPage.expectJoinsText( + `Collectors installed with this token join fleet ${fleet.name}, which has no configuration yet.`, + ); + + const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, newer.token)), http: true }; + await pm.fleetInstallPage.revealToken(); + const lines = FleetInstallPage.expectedInstallLines('linux', target); + await pm.fleetInstallPage.expectCommandLines(lines); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Fleet/fleet-navigation.spec.js b/tests/ui-testing/playwright-tests/Fleet/fleet-navigation.spec.js new file mode 100644 index 0000000000..5085bf531b --- /dev/null +++ b/tests/ui-testing/playwright-tests/Fleet/fleet-navigation.spec.js @@ -0,0 +1,94 @@ +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const { FLEET_YAML } = require('../../pages/fleetPages/fleetSeed.js'); + +test.describe('Fleet management navigation', { tag: ['@fleet', '@enterprise', '@all'] }, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + // Cleared first so a failed navigation never leaves afterEach the previous test's closed page. + pm = undefined; + await navigateToBase(page); + pm = new PageManager(page); + await pm.fleetManagerPage.assertEnabled(); + }); + + test.afterEach(async () => { + if (!pm) return; + await pm.fleetSeed.cleanup(); + expect(await pm.fleetSeed.survivors()).toEqual([]); + }); + + test('FL-GATE-01 · Data → Fleet management opens /web/fleet on the Overview', { + tag: ['@fleetNav', '@smoke', '@P0'], + }, async () => { + await pm.fleetManagerPage.openFromNav(); + await pm.fleetManagerPage.expectOnFleetRoute({ org_identifier: pm.fleetSeed.orgId() }); + await pm.fleetManagerPage.expectOverviewVisible(); + }); + + test('FL-NAV-01 · tabs set ?tab= by replace and keep org_identifier, so Back leaves the page', { + tag: ['@fleetNav', '@P1'], + }, async () => { + const org = pm.fleetSeed.orgId(); + await pm.fleetManagerPage.gotoFleet(); + await pm.fleetManagerPage.expectTabActive('overview'); + + await pm.fleetManagerPage.clickTab('fleets'); + await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'fleets', org_identifier: org }); + await pm.fleetManagerPage.clickTab('configurations'); + await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'configurations', org_identifier: org }); + await pm.fleetManagerPage.clickTab('audit'); + await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'audit', org_identifier: org }); + + await pm.fleetManagerPage.goBackAndExpectLeftFleet(); + }); + + test('FL-NAV-04 · an unknown ?fleet= says the fleet no longer exists', { + tag: ['@fleetNav', '@P2'], + }, async () => { + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: pm.fleetSeed.fleetName('missing') }); + await pm.fleetManagerPage.expectFleetNotFound('This fleet no longer exists'); + }); + + test('FL-NAV-05 · ?scope=all&status=offline shows the Fleet column, Offline pressed and no fleet panel', { + tag: ['@fleetNav', '@P1'], + }, async () => { + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', scope: 'all', status: 'offline' }); + await pm.fleetManagerPage.expectAllFleetsScope(); + await pm.fleetManagerPage.expectStatPressed('offline'); + await pm.fleetManagerPage.expectFleetPanelHidden(); + }); + + test('FL-NAV-08 · ?rollout= of fleet B with ?fleet=A opens that rollout and switches to fleet B', { + tag: ['@fleetNav', '@P1'], + }, async () => { + const fleetA = await pm.fleetSeed.createFleet('a'); + const fleetB = await pm.fleetSeed.createFleet('b'); + const config = await pm.fleetSeed.createConfig('nav', FLEET_YAML.publishable); + const version = await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable); + const rollout = await pm.fleetSeed.setDesired(fleetB.id, config, version); + await pm.fleetSeed.waitForRolloutStatus(rollout.id, 'completed'); + + await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id, rollout: rollout.id }); + await pm.fleetManagerPage.expectQueryParam('fleet', fleetB.id); + await pm.fleetFleetsPage.expectRailItemActive(fleetB.id); + await pm.fleetFleetsPage.expectRolloutDrawerStatus('No collectors targeted'); + }); + + test('FL-NAV-10 · the config editor deep link opens the editor and Back returns to ?tab=configurations', { + tag: ['@fleetNav', '@P2'], + }, async () => { + const config = await pm.fleetSeed.createConfig('deeplink', FLEET_YAML.seed); + await pm.fleetConfigPage.goto(config); + await pm.fleetConfigPage.expectTitle(config); + + await pm.fleetConfigPage.clickBack(); + await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'configurations', org_identifier: pm.fleetSeed.orgId() }); + await pm.fleetManagerPage.expectTabActive('configurations'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/IAM/iam-roles-editor.spec.js b/tests/ui-testing/playwright-tests/IAM/iam-roles-editor.spec.js index 6f121c87ea..c1349f7127 100644 --- a/tests/ui-testing/playwright-tests/IAM/iam-roles-editor.spec.js +++ b/tests/ui-testing/playwright-tests/IAM/iam-roles-editor.spec.js @@ -373,4 +373,19 @@ test.describe('IAM · Edit Role · navigation and filtering', { tag: '@enterpris expect(pairs.length, 'JSON view rendered no grants').toBeGreaterThan(0); expect(pairs).toEqual(stored.map((p) => `${p.object}|${p.permission}`).sort()); }); + + // FL-RBAC-01 · guards openobserve#15949: the no-op "Fleet Manager" permission must not return. + test('U-23 · the Fleet group lists Fleets, Fleet configurations and Fleet management, and no Fleet Manager', { + tag: ['@iam', '@iamRolesEditor', '@fleet', '@regression', '@P0', '@all'] + }, async () => { + await pm.fleetManagerPage.assertEnabled(); + const labels = await pm.rolesPage.railModuleLabels(); + expect(labels).toMatchObject({ + fleets: 'Fleets', + fleet_configs: 'Fleet configurations', + fleet_management: 'Fleet management', + }); + expect(Object.keys(labels)).not.toContain('fleet'); + expect(Object.values(labels)).not.toContain('Fleet Manager'); + }); }); diff --git a/tests/ui-testing/playwright-tests/cleanup.spec.js b/tests/ui-testing/playwright-tests/cleanup.spec.js index d3bd7ffce7..c911ab610a 100644 --- a/tests/ui-testing/playwright-tests/cleanup.spec.js +++ b/tests/ui-testing/playwright-tests/cleanup.spec.js @@ -494,6 +494,9 @@ test.describe("Pre-Test Cleanup", () => { // quietly rather than failing the cleanup every other suite depends on. await pm.apiCleanup.cleanupIamArtifacts('ui_auto'); + // Fleet management leftovers (Fleet/*.spec.js); enterprise-only, quiet on OSS. + await pm.apiCleanup.cleanupFleetArtifacts(['fl_auto_'], ['fl-auto-']); + testLogger.info('Pre-test cleanup completed successfully'); }); });