From 9e20cb2e5a98807cf0f41eb6b52bca07eb4d8e47 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 6 Feb 2026 10:49:15 +0100 Subject: [PATCH] fix(ui): optimize scans page polling to avoid redundant API calls (#9974) Co-authored-by: pedrooot --- .github/test-impact.yml | 17 ++- .github/workflows/ui-e2e-tests-v2.yml | 15 ++- ui/CHANGELOG.md | 8 ++ ui/app/(prowler)/scans/page.tsx | 25 +--- .../launch-scan-workflow-form.tsx | 3 + ui/components/scans/table/scans/index.ts | 1 + .../table/scans/scans-table-with-polling.tsx | 126 ++++++++++++++++++ ui/components/ui/custom/custom-table-link.tsx | 4 +- ui/tests/scans/scans-page.ts | 1 + 9 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 ui/components/scans/table/scans/scans-table-with-polling.tsx diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 3f90ad3d3a..0626f33707 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -14,7 +14,7 @@ ignored: - "*.md" - "**/*.md" - mkdocs.yml - + # Config files that don't affect runtime - .gitignore - .gitattributes @@ -23,7 +23,7 @@ ignored: - .backportrc.json - CODEOWNERS - LICENSE - + # IDE/Editor configs - .vscode/** - .idea/** @@ -31,10 +31,13 @@ ignored: # Examples and contrib (not production code) - examples/** - contrib/** - + # Skills (AI agent configs, not runtime) - skills/** - + + # E2E setup helpers (not runnable tests) + - ui/tests/setups/** + # Permissions docs - permissions/** @@ -47,18 +50,18 @@ critical: - prowler/config/** - prowler/exceptions/** - prowler/providers/common/** - + # API Core - api/src/backend/api/models.py - api/src/backend/config/** - api/src/backend/conftest.py - + # UI Core - ui/lib/** - ui/types/** - ui/config/** - ui/middleware.ts - + # CI/CD changes - .github/workflows/** - .github/test-impact.yml diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 8de1e9d9fa..caa712f3dd 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -25,7 +25,7 @@ jobs: e2e-tests: needs: impact-analysis if: | - github.repository == 'prowler-cloud/prowler' && + github.repository == 'prowler-cloud/prowler' && (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') runs-on: ubuntu-latest env: @@ -200,7 +200,14 @@ jobs: # e.g., "ui/tests/providers/**" -> "tests/providers" TEST_PATHS="${{ env.E2E_TEST_PATHS }}" # Remove ui/ prefix and convert ** to empty (playwright handles recursion) - TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u | tr '\n' ' ') + TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u) + # Drop auth setup helpers (not runnable test suites) + TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/') + if [[ -z "$TEST_PATHS" ]]; then + echo "No runnable E2E test paths after filtering setups" + exit 0 + fi + TEST_PATHS=$(echo "$TEST_PATHS" | tr '\n' ' ') echo "Resolved test paths: $TEST_PATHS" pnpm exec playwright test $TEST_PATHS fi @@ -222,8 +229,8 @@ jobs: skip-e2e: needs: impact-analysis if: | - github.repository == 'prowler-cloud/prowler' && - needs.impact-analysis.outputs.has-ui-e2e != 'true' && + github.repository == 'prowler-cloud/prowler' && + needs.impact-analysis.outputs.has-ui-e2e != 'true' && needs.impact-analysis.outputs.run-all != 'true' runs-on: ubuntu-latest steps: diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 3a667853a7..7ac2c70086 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.18.1] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Scans page polling now only refreshes scan table data instead of re-rendering the entire server component tree, eliminating redundant API calls to providers, findings, and compliance endpoints every 5 seconds + +--- + ## [1.18.0] (Prowler v5.18.0) ### 🔄 Changed diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index c3f1489714..2d0416f37c 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,21 +1,19 @@ import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; -import { getScans, getScansByState } from "@/actions/scans"; +import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; import { MutedFindingsConfigButton } from "@/components/providers"; import { - AutoRefresh, NoProvidersAdded, NoProvidersConnected, ScansFilters, } from "@/components/scans"; import { LaunchScanWorkflow } from "@/components/scans/launch-workflow"; import { SkeletonTableScans } from "@/components/scans/table"; -import { ColumnGetScans } from "@/components/scans/table/scans"; +import { ScansTableWithPolling } from "@/components/scans/table/scans"; import { ContentLayout } from "@/components/ui"; import { CustomBanner } from "@/components/ui/custom/custom-banner"; -import { DataTable } from "@/components/ui/table"; import { createProviderDetailsMapping, extractProviderUIDs, @@ -57,15 +55,6 @@ export default async function Scans({ const hasManageScansPermission = session?.user?.permissions?.manage_scans; - // Get scans data to check for executing scans - const scansData = await getScansByState(); - - const hasExecutingScan = scansData?.data?.some( - (scan: ScanProps) => - scan.attributes.state === "executing" || - scan.attributes.state === "available", - ); - // Extract provider UIDs and create provider details mapping for filtering const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; const providerDetails = providersData @@ -82,7 +71,6 @@ export default async function Scans({ return ( - <> <> {!hasManageScansPermission ? ( @@ -177,11 +165,10 @@ const SSRDataTableScans = async ({ }) || []; return ( - ); }; diff --git a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx index 7fb31d7e2a..b0047445c7 100644 --- a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx +++ b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx @@ -13,6 +13,7 @@ import { Form } from "@/components/ui/form"; import { toast } from "@/components/ui/toast"; import { onDemandScanFormSchema } from "@/types"; +import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling"; import { SelectScanProvider } from "./select-scan-provider"; type ProviderInfo = { @@ -85,6 +86,8 @@ export const LaunchScanWorkflow = ({ }); // Reset form after successful submission form.reset(); + // Notify the scans table to refresh and pick up the new scan + window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT)); } }; diff --git a/ui/components/scans/table/scans/index.ts b/ui/components/scans/table/scans/index.ts index 2567c0098c..18c3035f7d 100644 --- a/ui/components/scans/table/scans/index.ts +++ b/ui/components/scans/table/scans/index.ts @@ -1,3 +1,4 @@ export * from "./column-get-scans"; export * from "./data-table-row-actions"; export * from "./data-table-row-details"; +export * from "./scans-table-with-polling"; diff --git a/ui/components/scans/table/scans/scans-table-with-polling.tsx b/ui/components/scans/table/scans/scans-table-with-polling.tsx new file mode 100644 index 0000000000..5c59a5f267 --- /dev/null +++ b/ui/components/scans/table/scans/scans-table-with-polling.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { getScans } from "@/actions/scans"; +import { AutoRefresh } from "@/components/scans"; +import { DataTable } from "@/components/ui/table"; +import { MetaDataProps, ScanProps, SearchParamsProps } from "@/types"; + +import { ColumnGetScans } from "./column-get-scans"; + +export const SCAN_LAUNCHED_EVENT = "scan-launched"; + +interface ScansTableWithPollingProps { + initialData: ScanProps[]; + initialMeta?: MetaDataProps; + searchParams: SearchParamsProps; +} + +const EXECUTING_STATES = ["executing", "available"] as const; + +function expandScansWithProviderInfo( + scans: ScanProps[], + included?: Array<{ type: string; id: string; attributes: any }>, +) { + return ( + scans?.map((scan) => { + const providerId = scan.relationships?.provider?.data?.id; + + if (!providerId) { + return { ...scan, providerInfo: undefined }; + } + + const providerData = included?.find( + (item) => item.type === "providers" && item.id === providerId, + ); + + if (!providerData) { + return { ...scan, providerInfo: undefined }; + } + + return { + ...scan, + providerInfo: { + provider: providerData.attributes.provider, + uid: providerData.attributes.uid, + alias: providerData.attributes.alias, + }, + }; + }) || [] + ); +} + +export function ScansTableWithPolling({ + initialData, + initialMeta, + searchParams, +}: ScansTableWithPollingProps) { + const [scansData, setScansData] = useState(initialData); + const [meta, setMeta] = useState(initialMeta); + + const hasExecutingScan = scansData.some((scan) => + EXECUTING_STATES.includes( + scan.attributes.state as (typeof EXECUTING_STATES)[number], + ), + ); + + const handleRefresh = useCallback(async () => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const sort = searchParams.sort?.toString(); + + const filters = Object.fromEntries( + Object.entries(searchParams).filter( + ([key]) => key.startsWith("filter[") && key !== "scanId", + ), + ); + + const query = (filters["filter[search]"] as string) || ""; + + const result = await getScans({ + query, + page, + sort, + filters, + pageSize, + include: "provider", + }); + + if (result?.data) { + const expanded = expandScansWithProviderInfo( + result.data, + result.included, + ); + setScansData(expanded); + + if (result && "meta" in result) { + setMeta(result.meta as MetaDataProps); + } + } + }, [searchParams]); + + // Listen for scan launch events to trigger an immediate refresh + useEffect(() => { + const handler = () => { + handleRefresh(); + }; + window.addEventListener(SCAN_LAUNCHED_EVENT, handler); + return () => window.removeEventListener(SCAN_LAUNCHED_EVENT, handler); + }, [handleRefresh]); + + return ( + <> + + + + ); +} diff --git a/ui/components/ui/custom/custom-table-link.tsx b/ui/components/ui/custom/custom-table-link.tsx index 80b6b000bf..9fba1e2c55 100644 --- a/ui/components/ui/custom/custom-table-link.tsx +++ b/ui/components/ui/custom/custom-table-link.tsx @@ -21,7 +21,9 @@ export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => { return ( ); }; diff --git a/ui/tests/scans/scans-page.ts b/ui/tests/scans/scans-page.ts index e3e19804fc..6f0052873d 100644 --- a/ui/tests/scans/scans-page.ts +++ b/ui/tests/scans/scans-page.ts @@ -105,6 +105,7 @@ export class ScansPage extends BasePage { await expect(this.scanTable).toBeVisible(); // Find a row that contains the account ID (provider UID in Cloud Provider column) + // Note: Use a more specific locator strategy if possible in the future const rowWithAccountId = this.scanTable .locator("tbody tr") .filter({ hasText: accountId })