fix(ui): optimize scans page polling to avoid redundant API calls (#9974)

Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
Alan Buscaglia
2026-02-06 10:49:15 +01:00
committed by GitHub
parent cb76e77851
commit 9e20cb2e5a
9 changed files with 169 additions and 31 deletions
+10 -7
View File
@@ -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
+11 -4
View File
@@ -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:
+8
View File
@@ -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
+6 -19
View File
@@ -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 (
<ContentLayout title="Scans" icon="lucide:timer">
<AutoRefresh hasExecutingScan={hasExecutingScan} />
<>
<>
{!hasManageScansPermission ? (
@@ -177,11 +165,10 @@ const SSRDataTableScans = async ({
}) || [];
return (
<DataTable
key={`scans-${Date.now()}`}
columns={ColumnGetScans}
data={expandedScansData || []}
metadata={meta}
<ScansTableWithPolling
initialData={expandedScansData}
initialMeta={meta}
searchParams={searchParams}
/>
);
};
@@ -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));
}
};
+1
View File
@@ -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";
@@ -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<ScanProps[]>(initialData);
const [meta, setMeta] = useState<MetaDataProps | undefined>(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 (
<>
<AutoRefresh
hasExecutingScan={hasExecutingScan}
onRefresh={handleRefresh}
/>
<DataTable
key={`scans-${scansData.length}-${meta?.pagination?.page}`}
columns={ColumnGetScans}
data={scansData}
metadata={meta}
/>
</>
);
}
@@ -21,7 +21,9 @@ export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => {
return (
<Button asChild variant="link" size="sm" className="text-xs">
<Link href={href}>{label}</Link>
<Link href={href} prefetch={false}>
{label}
</Link>
</Button>
);
};
+1
View File
@@ -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 })