From 7c0b87c87cfcdc2f6e85be2110480a6ef95d98f4 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 7 Jul 2026 15:55:28 +0200 Subject: [PATCH] chore(revision): add missing changes --- ui/actions/compliances/compliances.ts | 6 ++ .../compliance/[compliancetitle]/page.tsx | 57 +++++----- ui/app/(prowler)/compliance/page.tsx | 60 +++++++---- .../client-accordion-content.tsx | 78 +++++++++++--- ...compliance-accordion-requeriment-title.tsx | 8 +- .../use-requirement-findings.test.ts | 94 ++++++++++++++++ .../use-requirement-findings.ts | 100 +++++++++++++++++- .../cross-provider-detail-client.tsx | 31 ++++-- .../cross-provider/cross-provider-detail.tsx | 7 +- .../cross-provider-explorer-card.tsx | 48 +++++---- .../cross-provider-pdf-watcher.test.tsx | 27 +++++ .../cross-provider-pdf-watcher.tsx | 78 +++++++++++--- .../cross-provider/generate-pdf-button.tsx | 36 ++++--- .../cross-provider-insights.test.ts | 89 ++++++++++++++++ ui/lib/compliance/cross-provider-insights.ts | 14 ++- ui/tailwind.config.js | 12 +-- 16 files changed, 595 insertions(+), 150 deletions(-) diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 0c8c9d439e..347ff668cc 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -297,6 +297,7 @@ export const generateCrossProviderCompliancePdf = async ({ providerTypes, providerIds, providerGroups, + regions, onlyFailed, includeManual, reportName, @@ -306,6 +307,7 @@ export const generateCrossProviderCompliancePdf = async ({ providerTypes?: string | string[]; providerIds?: string | string[]; providerGroups?: string | string[]; + regions?: string | string[]; onlyFailed?: boolean; includeManual?: boolean; /** Optional user-chosen download filename. Sanitized server-side (a @@ -333,6 +335,7 @@ export const generateCrossProviderCompliancePdf = async ({ setParam("filter[provider_type__in]", providerTypes); setParam("filter[provider_id__in]", providerIds); setParam("filter[provider_groups__in]", providerGroups); + setParam("filter[region__in]", regions); setParam("report_name", reportName); if (onlyFailed !== undefined) { url.searchParams.set("only_failed", String(onlyFailed)); @@ -465,12 +468,14 @@ export const getLatestCrossProviderCompliancePdf = async ({ providerTypes, providerIds, providerGroups, + regions, }: { complianceId: string; scanIds?: string[]; providerTypes?: string | string[]; providerIds?: string | string[]; providerGroups?: string | string[]; + regions?: string | string[]; }): Promise => { const headers = await getAuthHeaders({ contentType: false }); @@ -493,6 +498,7 @@ export const getLatestCrossProviderCompliancePdf = async ({ setParam("filter[provider_type__in]", providerTypes); setParam("filter[provider_id__in]", providerIds); setParam("filter[provider_groups__in]", providerGroups); + setParam("filter[region__in]", regions); try { const response = await fetch(url.toString(), { headers }); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index a1371cfd66..d6e1296b22 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -94,32 +94,18 @@ export default async function ComplianceDetail({ } const crossProviderTitle = compliancetitle.split("-").join(" "); - const [ - crossProviderResponse, - providersData, - providerGroupsData, - latestPdfReport, - ] = await Promise.all([ - getCrossProviderComplianceOverview({ - complianceId, - providerTypes: providerTypeFilter, - providerIds: providerIdFilter, - providerGroups: providerGroupsFilter, - regions: regionFilter, - }), - getAllProviders(), - getAllProviderGroups(), - // Independent of the overview fetch above — the backend resolves the - // same "latest scan per filtered provider" rule from these same raw - // filters, so this can run in parallel instead of waterfalling behind - // the overview response just to read its resolved scan_ids. - getLatestCrossProviderCompliancePdf({ - complianceId, - providerTypes: providerTypeFilter, - providerIds: providerIdFilter, - providerGroups: providerGroupsFilter, - }), - ]); + const [crossProviderResponse, providersData, providerGroupsData] = + await Promise.all([ + getCrossProviderComplianceOverview({ + complianceId, + providerTypes: providerTypeFilter, + providerIds: providerIdFilter, + providerGroups: providerGroupsFilter, + regions: regionFilter, + }), + getAllProviders(), + getAllProviderGroups(), + ]); if (!crossProviderResponse || "redirectTo" in crossProviderResponse) { // A 402 (payment required) resolves to ``{ redirectTo: "/billing" }`` — @@ -181,6 +167,24 @@ export default async function ComplianceDetail({ const compatibleProviders = (providersData?.data || []).filter((provider) => compatibleProviderTypes.has(provider.attributes.provider), ); + + // Resolve the "latest PDF" check AFTER the overview, scoped to the EXACT + // scans the overview resolved (``attributes.scan_ids``) rather than + // re-deriving "latest scan per provider" from the raw filters a second + // time. Running them independently could offer a "Download" whose scan set + // differs from the displayed overview if a scan completed between the two + // calls. The overview aggregation dominates the wall-clock, so serializing + // this quick existence check behind it is a negligible cost for a + // guaranteed-consistent download button. + const latestPdfReport = await getLatestCrossProviderCompliancePdf({ + complianceId, + scanIds: crossProviderData.attributes.scan_ids, + providerTypes: providerTypeFilter, + providerIds: providerIdFilter, + providerGroups: providerGroupsFilter, + regions: regionFilter, + }); + return (
); - // Only build (and thus fetch) the cross-provider grid in Cloud. In OSS the - // tab is disabled, so there is no content to render and no endpoint to hit. - const crossProviderContent = crossProviderEnabled ? ( - - - - } - > - - - ) : null; + // Only build (and thus fetch) the cross-provider grid in Cloud AND only when + // its tab is active. In OSS the tab is disabled; on the Per Scan tab it isn't + // rendered, so there is no content to build and no aggregation endpoint to + // hit until the user actually navigates to Cross-Provider. + const crossProviderContent = + crossProviderEnabled && isCrossProviderTab ? ( + + + + } + > + + + ) : null; return ( { - // FindingStatus shares the same wire values for PASS/FAIL/MANUAL. - return status === "No findings" ? "MANUAL" : status; -}; +// PASS/FAIL/MANUAL share the same wire values as FindingStatus. "No findings" +// is NOT one of them — it means the provider's checks produced no findings at +// all, which is neither a manual control nor a pass/fail verdict. The caller +// renders it as a distinct neutral chip instead of coercing it into a +// (misleading) "Manual" badge. +const NO_FINDINGS_STATUS: RequirementStatus = "No findings"; export const ClientAccordionContent = ({ requirement, @@ -88,6 +93,7 @@ export const ClientAccordionContent = ({ isLoading, error, isPartial, + crossProviderScanMeta, patchTriageUpdate, reload, } = useRequirementFindings({ @@ -121,35 +127,51 @@ export const ClientAccordionContent = ({ patchTriageUpdate(input); }; - // Per-provider finding tallies for the cross-provider breakdown. Derived - // from the merged ``findings`` (mapping each row to its provider via - // ``scan_ids_by_provider``) so the counts always match the unified table. + // Per-provider finding tallies for the cross-provider breakdown. + // + // ``count`` is authoritative: it sums each of the provider's scans' real + // ``pagination.count`` (total findings for its checks), NOT the loaded page + // rows — a provider with 300 findings must read 300 here even while the + // table shows page 1. + // + // ``pass``/``fail`` can only be tallied from loaded rows, so they're honest + // only when every one of the provider's scans returned all its rows on this + // page (``pages <= 1``). When a provider is paginated, ``fullyLoaded`` is + // false and the caller renders "—" instead of a misleading partial split. const providerFindingStats = (() => { const count: Record = {}; const pass: Record = {}; const fail: Record = {}; + const fullyLoaded: Record = {}; if (!isCrossProvider || !scanIdsByProvider) { - return { count, pass, fail }; + return { count, pass, fail, fullyLoaded }; } const scanToProvider = new Map(); for (const [providerKey, scanIds] of Object.entries(scanIdsByProvider)) { count[providerKey] = 0; pass[providerKey] = 0; fail[providerKey] = 0; + fullyLoaded[providerKey] = true; if (Array.isArray(scanIds)) { - for (const sid of scanIds) scanToProvider.set(sid, providerKey); + for (const sid of scanIds) { + scanToProvider.set(sid, providerKey); + const meta = crossProviderScanMeta[sid]; + // No meta yet (scan errored or still loading) or the scan spans + // multiple pages → this provider's pass/fail can't be trusted. + count[providerKey] += meta?.count ?? 0; + if (!meta || meta.pages > 1) fullyLoaded[providerKey] = false; + } } } for (const row of findings?.data ?? []) { const sid = row.relationships?.scan?.data?.id; const providerKey = sid ? scanToProvider.get(sid) : undefined; if (!providerKey) continue; - count[providerKey] += 1; const status = row.attributes?.status; if (status === "PASS") pass[providerKey] += 1; else if (status === "FAIL") fail[providerKey] += 1; } - return { count, pass, fail }; + return { count, pass, fail, fullyLoaded }; })(); const renderDetails = () => { @@ -200,6 +222,10 @@ export const ClientAccordionContent = ({ const findingsCount = providerFindingStats.count[providerKey]; const passCount = providerFindingStats.pass[providerKey] ?? 0; const failCount = providerFindingStats.fail[providerKey] ?? 0; + // Pass/Fail is only honest when the provider's rows are fully + // loaded (single page per scan); otherwise it's a page sample. + const passFailKnown = + providerFindingStats.fullyLoaded[providerKey] ?? false; return ( - + {providerStatus === NO_FINDINGS_STATUS ? ( + + No findings + + ) : ( + + )} {findingsLoaded ? (findingsCount ?? 0) : "—"} - {findingsLoaded ? ( + {findingsLoaded && passFailKnown ? ( {passCount} @@ -239,7 +274,16 @@ export const ClientAccordionContent = ({ {failCount} ) : ( - — + + — + )} diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx index 2f09e9130a..b62319d79c 100644 --- a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx @@ -43,7 +43,13 @@ export const ComplianceAccordionRequirementTitle = ({ {type} )} - {name} + {/* ``truncate`` keeps the row single-line so the status/chips cluster + stays aligned, but the full name would otherwise be unreadable once + ellipsized (most CIS/ENS/ISO names are sentence-length). ``title`` + surfaces it on hover/focus so nothing is lost. */} + + {name} + {invalidConfig && }
{/* The accordion trigger this title lives in applies ``hover:underline`` diff --git a/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts b/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts index 5327d34109..1f61ca9d28 100644 --- a/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts +++ b/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts @@ -348,6 +348,100 @@ describe("useRequirementFindings", () => { expect(result.current.findings?.meta?.pagination?.count).toBe(3); }); + it("takes the worst-case page count across scans so pagination reaches every tail", async () => { + findingsActionsMock.getFindings.mockReset(); + findingsActionsMock.getFindings + .mockResolvedValueOnce({ + data: [ + { + id: "finding-a", + attributes: { status: "FAIL", severity: "high" }, + relationships: { scan: { data: { id: "scan-a" } } }, + }, + ], + included: [{ type: "scans", id: "scan-a" }], + // AWS spans many pages… + meta: { pagination: { count: 30, pages: 3 } }, + }) + .mockResolvedValueOnce({ + data: [ + { + id: "finding-b", + attributes: { status: "PASS", severity: "low" }, + relationships: { scan: { data: { id: "scan-b" } } }, + }, + ], + included: [{ type: "scans", id: "scan-b" }], + // …Azure fits in one. + meta: { pagination: { count: 5, pages: 1 } }, + }); + + const { result } = renderHook(() => + useRequirementFindings( + defaultOptions({ + isCrossProvider: true, + scanIdsByProvider: { aws: ["scan-a"], azure: ["scan-b"] }, + checkIdsByProvider: { aws: ["check_a"], azure: ["check_b"] }, + scopeSignature: "scope-pages", + }), + ), + ); + await flushAsync(); + + // The merged envelope must expose 3 pages (not the hardcoded 1) so the + // table's Next button stays enabled and AWS's later pages are reachable. + expect(result.current.findings?.meta?.pagination?.pages).toBe(3); + expect(result.current.findings?.meta?.pagination?.count).toBe(35); + }); + + it("globally re-sorts the merged rows by the active sort (FAIL/critical first)", async () => { + findingsActionsMock.getFindings.mockReset(); + // Scan A returns a PASS/low row; scan B a FAIL/critical row. Concatenation + // alone would list A before B and contradict the FAIL-first sort. + findingsActionsMock.getFindings + .mockResolvedValueOnce({ + data: [ + { + id: "finding-pass", + attributes: { status: "PASS", severity: "low" }, + relationships: { scan: { data: { id: "scan-a" } } }, + }, + ], + included: [{ type: "scans", id: "scan-a" }], + meta: { pagination: { count: 1, pages: 1 } }, + }) + .mockResolvedValueOnce({ + data: [ + { + id: "finding-fail", + attributes: { status: "FAIL", severity: "critical" }, + relationships: { scan: { data: { id: "scan-b" } } }, + }, + ], + included: [{ type: "scans", id: "scan-b" }], + meta: { pagination: { count: 1, pages: 1 } }, + }); + + const { result } = renderHook(() => + useRequirementFindings( + defaultOptions({ + // Family A default: status,severity,-inserted_at → FAIL/critical first. + sort: "status,severity,-inserted_at", + isCrossProvider: true, + scanIdsByProvider: { aws: ["scan-a"], azure: ["scan-b"] }, + checkIdsByProvider: { aws: ["check_a"], azure: ["check_b"] }, + scopeSignature: "scope-sort", + }), + ), + ); + await flushAsync(); + + expect(result.current.findings?.data?.map((f) => f.id)).toEqual([ + "finding-fail", + "finding-pass", + ]); + }); + it("should not fetch in cross-provider mode when no scans contribute", async () => { // Given / When renderHook(() => diff --git a/ui/components/compliance/compliance-accordion/use-requirement-findings.ts b/ui/components/compliance/compliance-accordion/use-requirement-findings.ts index 61b25a73fc..5d41e17e1d 100644 --- a/ui/components/compliance/compliance-accordion/use-requirement-findings.ts +++ b/ui/components/compliance/compliance-accordion/use-requirement-findings.ts @@ -41,6 +41,15 @@ interface UseRequirementFindingsOptions { scopeSignature?: string; } +// Authoritative per-scan findings metadata (the total ``count`` for that +// scan's checks, and how many ``pages`` it spans). Sourced from each +// per-scan response's pagination — not from the loaded rows — so the +// per-provider breakdown can show real totals instead of a page-1 sample. +export interface CrossProviderScanMeta { + count: number; + pages: number; +} + interface UseRequirementFindingsReturn { findings: FindingsResponse | null; expandedFindings: FindingProps[]; @@ -51,23 +60,78 @@ interface UseRequirementFindingsReturn { // The caller surfaces a warning instead of presenting the partial data as // complete. isPartial: boolean; + // Cross-provider only: authoritative per-scan totals keyed by scan id, so + // the per-provider breakdown reports real finding counts (and can tell + // whether a provider's rows are fully loaded) rather than a page sample. + crossProviderScanMeta: Record; patchTriageUpdate: (input: UpdateFindingTriageInput) => void; reload: () => void; } const FINDINGS_LOAD_ERROR = "Could not load findings."; +// Client-side mirror of the API's plain-findings ("Family A") sort semantics +// (see ``lib/findings-sort.ts``): ``status`` and ``severity`` are Postgres +// ENUM columns sorted by DECLARATION order, so the bare (ascending) token +// already puts FAIL / critical first. The merge below concatenates rows that +// each arrived sorted per scan — without a global re-sort the blocks would +// interleave by provider and contradict the table's sort indicator. +const ENUM_SORT_ORDER: Record> = { + severity: { critical: 0, high: 1, medium: 2, low: 3, informational: 4 }, + status: { FAIL: 0, PASS: 1, MANUAL: 2 }, +}; + +const compareBySortTokens = ( + a: FindingProps, + b: FindingProps, + tokens: string[], +): number => { + for (const token of tokens) { + const desc = token.startsWith("-"); + const field = desc ? token.slice(1) : token; + const rank = ENUM_SORT_ORDER[field]; + const rawA = (a.attributes as Record | undefined)?.[field]; + const rawB = (b.attributes as Record | undefined)?.[field]; + + let cmp = 0; + if (rank) { + const rankA = rank[String(rawA)] ?? Number.MAX_SAFE_INTEGER; + const rankB = rank[String(rawB)] ?? Number.MAX_SAFE_INTEGER; + cmp = rankA - rankB; + } else if (typeof rawA === "number" && typeof rawB === "number") { + cmp = rawA - rawB; + } else { + // ISO timestamps (``inserted_at``/``updated_at``) and plain strings + // both compare correctly lexicographically; missing values sort last. + const isMissing = (v: unknown) => v === null || v === undefined; + const strA = isMissing(rawA) ? "" : String(rawA); + const strB = isMissing(rawB) ? "" : String(rawB); + if (strA === strB) continue; + if (strA === "") return 1; + if (strB === "") return -1; + cmp = strA < strB ? -1 : 1; + } + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; +}; + // Merge the per-scan responses of a cross-provider fan-out into a single -// JSON:API-shaped envelope: concatenate every ``data`` row, dedupe the -// ``included`` records by ``(type, id)`` (the same provider/scan object -// repeats across responses), and sum the per-scan counts. +// JSON:API-shaped envelope: concatenate every ``data`` row (globally +// re-sorted by the active sort), dedupe the ``included`` records by +// ``(type, id)`` (the same provider/scan object repeats across responses), +// sum the per-scan counts, and take the WORST-CASE page count so pagination +// can reach every scan's tail (each page-N fetch fans out per scan; scans +// past their last page simply return no rows). const mergeCrossProviderResponses = ( responses: unknown[], page: number, + sort: string, ): FindingsResponseLike => { const allData: FindingProps[] = []; const allIncluded: { type: string; id: string }[] = []; let totalCount = 0; + let maxPages = 1; for (const r of responses) { if (!r || typeof r !== "object" || !("data" in r)) continue; @@ -75,6 +139,15 @@ const mergeCrossProviderResponses = ( allData.push(...(typedResponse.data || [])); allIncluded.push(...(typedResponse.included || [])); totalCount += typedResponse?.meta?.pagination?.count || 0; + maxPages = Math.max(maxPages, typedResponse?.meta?.pagination?.pages || 1); + } + + const sortTokens = sort + .split(",") + .map((token) => token.trim()) + .filter(Boolean); + if (sortTokens.length > 0) { + allData.sort((a, b) => compareBySortTokens(a, b, sortTokens)); } const dedupedIncluded: typeof allIncluded = []; @@ -90,7 +163,7 @@ const mergeCrossProviderResponses = ( data: allData, included: dedupedIncluded, meta: { - pagination: { page, pages: 1, count: totalCount }, + pagination: { page, pages: maxPages, count: totalCount }, version: "", }, }; @@ -114,6 +187,9 @@ export function useRequirementFindings({ const [expandedFindings, setExpandedFindings] = useState([]); const [error, setError] = useState(null); const [isPartial, setIsPartial] = useState(false); + const [crossProviderScanMeta, setCrossProviderScanMeta] = useState< + Record + >({}); const [reloadNonce, setReloadNonce] = useState(0); // Depend on the joined value, not the array: the requirement prop gets a @@ -225,11 +301,26 @@ export function useRequirementFindings({ return; } + // Authoritative per-scan totals, aligned by index with ``jobs`` + // (``responses[i]`` answers ``jobs[i]``). The breakdown reads these + // for real per-provider counts instead of counting loaded rows. + const scanMeta: Record = {}; + responses.forEach((r, index) => { + if (!r || typeof r !== "object" || !("data" in r)) return; + const pagination = (r as FindingsResponseLike).meta?.pagination; + scanMeta[jobs[index].scanIdForAccount] = { + count: pagination?.count ?? 0, + pages: pagination?.pages ?? 1, + }; + }); + const merged = mergeCrossProviderResponses( responses, parseInt(pageNumber, 10), + encodedSort, ); setFindings(merged); + setCrossProviderScanMeta(scanMeta); expandFindings(merged); // Some — but not all — scans failed: keep the successful data but // flag the merge as incomplete so the caller can warn the user. @@ -301,6 +392,7 @@ export function useRequirementFindings({ isLoading, error, isPartial, + crossProviderScanMeta, patchTriageUpdate, reload, }; diff --git a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx index 03afa705a2..db1b53aa51 100644 --- a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { LatestCrossProviderPdfReport } from "@/actions/compliances"; +import { useMountEffect } from "@/hooks/use-mount-effect"; import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights"; import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; import type { ProviderGroup } from "@/types/components"; @@ -17,12 +18,13 @@ interface CrossProviderDetailClientProps { providers: ProviderProps[]; providerGroups: ProviderGroup[]; /** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / - * ``filter[provider_groups__in]`` values from the URL — threaded down to - * the "Generate PDF" button so it respects the same narrowing as this - * already-filtered ``attributes`` payload. */ + * ``filter[provider_groups__in]`` / ``filter[region__in]`` values from the + * URL — threaded down to the "Generate PDF" button so it respects the same + * narrowing as this already-filtered ``attributes`` payload. */ providerTypeFilter?: string; providerIdFilter?: string; providerGroupsFilter?: string; + regionFilter?: string; latestPdfReport: LatestCrossProviderPdfReport | null; } @@ -52,9 +54,16 @@ export const CrossProviderDetailClient = ({ providerTypeFilter, providerIdFilter, providerGroupsFilter, + regionFilter, latestPdfReport, }: CrossProviderDetailClientProps) => { - const insights = computeCrossProviderInsights(attributes); + // Derived once per payload — ``computeCrossProviderInsights`` iterates every + // requirement, so recomputing it on each anchor-scroll state change would be + // wasteful. + const insights = useMemo( + () => computeCrossProviderInsights(attributes), + [attributes], + ); const accordionContainerRef = useRef(null); const flashTimeoutRef = useRef | null>(null); @@ -63,12 +72,11 @@ export const CrossProviderDetailClient = ({ >(null); // Clear any pending flash timer on unmount so its callback never runs - // against a detached anchor node. - useEffect(() => { - return () => { - if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current); - }; - }, []); + // against a detached anchor node. ``useMountEffect`` is the project-approved + // wrapper for a mount/unmount lifecycle (never a bare ``useEffect``). + useMountEffect(() => () => { + if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current); + }); const handleDomainSelect = (domainName: string) => { // Setting the forced key expands the target section. Selecting a domain @@ -119,6 +127,7 @@ export const CrossProviderDetailClient = ({ providerTypeFilter={providerTypeFilter} providerIdFilter={providerIdFilter} providerGroupsFilter={providerGroupsFilter} + regionFilter={regionFilter} latestPdfReport={latestPdfReport} /> diff --git a/ui/components/compliance/cross-provider/cross-provider-detail.tsx b/ui/components/compliance/cross-provider/cross-provider-detail.tsx index bc120ef790..85e01eb715 100644 --- a/ui/components/compliance/cross-provider/cross-provider-detail.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-detail.tsx @@ -16,11 +16,12 @@ interface CrossProviderDetailProps { * every other provider-group filter in the app). */ providerGroups: ProviderGroup[]; /** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / - * ``filter[provider_groups__in]`` values from the URL — threaded down to - * the "Generate PDF" button. */ + * ``filter[provider_groups__in]`` / ``filter[region__in]`` values from the + * URL — threaded down to the "Generate PDF" button. */ providerTypeFilter?: string; providerIdFilter?: string; providerGroupsFilter?: string; + regionFilter?: string; /** A previously-generated PDF matching these exact filters, resolved * server-side alongside ``attributes`` — ``null`` means none exists yet * (or it went stale) for the current filters, so the "Generate PDF" @@ -44,6 +45,7 @@ export const CrossProviderDetail = ({ providerTypeFilter, providerIdFilter, providerGroupsFilter, + regionFilter, latestPdfReport, }: CrossProviderDetailProps) => { return ( @@ -54,6 +56,7 @@ export const CrossProviderDetail = ({ providerTypeFilter={providerTypeFilter} providerIdFilter={providerIdFilter} providerGroupsFilter={providerGroupsFilter} + regionFilter={regionFilter} latestPdfReport={latestPdfReport} /> ); diff --git a/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx index fbabbc1aaf..da56c812cb 100644 --- a/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Maximize2, Minimize2, X } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { LatestCrossProviderPdfReport } from "@/actions/compliances"; import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; @@ -20,7 +20,6 @@ import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { crossProviderToMapperInput } from "@/lib/compliance/cross-provider-adapter"; import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights"; -import { cn } from "@/lib/utils"; import type { CrossProviderComplianceOverviewAttributes, CrossProviderRequirement, @@ -39,13 +38,14 @@ interface CrossProviderExplorerCardProps { * click expands and scrolls to the target row. */ forcedExpandedSectionKey?: string | null; /** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / - * ``filter[provider_groups__in]`` values currently applied via - * ``CrossProviderFilters`` — threaded through to ``GeneratePdfButton`` - * so the generated PDF respects the same narrowing as the on-screen - * view. */ + * ``filter[provider_groups__in]`` / ``filter[region__in]`` values currently + * applied via ``CrossProviderFilters`` — threaded through to + * ``GeneratePdfButton`` so the generated PDF respects the same narrowing as + * the on-screen view. */ providerTypeFilter?: string; providerIdFilter?: string; providerGroupsFilter?: string; + regionFilter?: string; /** A previously-generated PDF matching the current filters, resolved * server-side — ``null`` means "Generate PDF" should show, not * "Download PDF". */ @@ -85,6 +85,7 @@ export const CrossProviderExplorerCard = ({ providerTypeFilter, providerIdFilter, providerGroupsFilter, + regionFilter, latestPdfReport, }: CrossProviderExplorerCardProps) => { const [searchTerm, setSearchTerm] = useState(""); @@ -98,8 +99,10 @@ export const CrossProviderExplorerCard = ({ // Filtered attributes drive both the match counter and the accordion // content. The unfiltered ``insights`` continue to feed the heatmap // matrix per section so global counts stay stable while the user - // narrows their search. - const filteredAttributes = (() => { + // narrows their search. Memoised so opening/closing a section (a state + // change unrelated to the filter inputs) doesn't re-scan every + // requirement. + const filteredAttributes = useMemo(() => { const lowerTerm = searchTerm.trim().toLowerCase(); if (lowerTerm === "" && statusFilters.length === 0) { return attributes; @@ -113,7 +116,7 @@ export const CrossProviderExplorerCard = ({ return haystack.includes(lowerTerm); }); return { ...attributes, requirements: filteredRequirements }; - })(); + }, [attributes, searchTerm, statusFilters]); const matchCount = filteredAttributes.requirements.length; const totalCount = attributes.requirements.length; @@ -122,8 +125,11 @@ export const CrossProviderExplorerCard = ({ // Frameworks / categories / requirements derived from the mapper. We // run this against the *filtered* attribute set so the list shrinks // as the user types — sections with no surviving requirements - // disappear entirely instead of expanding to an empty body. - const { sections, allSectionKeys, statsByName } = (() => { + // disappear entirely instead of expanding to an empty body. Memoised + // because ``mapComplianceData`` re-iterates every requirement; without + // it, every accordion open/close (a frequent interaction) would + // re-derive the whole tree. + const { sections, allSectionKeys, statsByName } = useMemo(() => { const mapper = getComplianceMapper(filteredAttributes.framework); const { attributesData, requirementsData } = crossProviderToMapperInput(filteredAttributes); @@ -150,7 +156,7 @@ export const CrossProviderExplorerCard = ({ allSectionKeys: allSections.map((s) => s.key), statsByName: stats, }; - })(); + }, [filteredAttributes, insights.domainStats]); const expandedKeys = (() => { if (allSectionsOpen) return allSectionKeys; @@ -224,6 +230,7 @@ export const CrossProviderExplorerCard = ({ providerTypes={providerTypeFilter} providerIds={providerIdFilter} providerGroups={providerGroupsFilter} + regions={regionFilter} latestPdfReport={latestPdfReport} frameworkLabel={ attributes.name || @@ -292,13 +299,7 @@ export const CrossProviderExplorerCard = ({ value={section.key} className="px-3" > - + {stats ? ( - {requirements.map((requirement, idx) => { + {requirements.map((requirement) => { const xprov = requirement as CrossProviderRequirement; - const itemKey = `${sectionKey}-req-${idx}`; + // Key off the requirement's stable name (it embeds the unique + // framework requirement id, e.g. "AAC-01 - …") rather than the array + // index: with an index key, filtering the list would hand an + // already-open row's expansion state to whatever requirement slid into + // that position. + const itemKey = `${sectionKey}-req-${requirement.name}`; return ( { ).toBe("failed"); }); + it("gives up on a generation wedged in a running state past the ceiling", async () => { + // The task never leaves ``executing`` — the watcher must eventually stop + // polling it instead of ticking forever. + getTaskMock.mockResolvedValue({ + data: { attributes: { state: "executing" } }, + }); + render(); + + useCrossProviderPdfStore.getState().trackGeneration({ + taskId: "task-wedged", + signature: "sig", + reportUrl: "/x", + }); + + // 200 ticks is the ceiling; advance just past it. + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 201); + + expect( + useCrossProviderPdfStore.getState().generations["task-wedged"].status, + ).toBe("failed"); + const timeoutToast = toastMock.mock.calls + .map((call) => call[0]) + .find((payload) => payload.title === "PDF generation timed out"); + expect(timeoutToast).toBeTruthy(); + expect(timeoutToast.variant).toBe("destructive"); + }); + it("surfaces a terminal failure state with the task's error message", async () => { getTaskMock.mockResolvedValue({ data: { diff --git a/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.tsx b/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.tsx index b69092a339..327999d5eb 100644 --- a/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.tsx @@ -28,19 +28,18 @@ const RUNNING_STATES = new Set([ // consecutive failures. const MAX_CONSECUTIVE_POLL_ERRORS = 3; +// Hard ceiling on how long a single generation is polled. A task wedged in +// ``executing`` server-side would otherwise be polled forever; after this many +// ticks (~10 min at a 3s interval) we stop tracking it and tell the user. +const MAX_POLL_TICKS = 200; + /** - * Single, app-wide poller for cross-provider PDF generations. - * - * Mounted once in the ``(prowler)`` layout so it survives navigation between - * views. When the user clicks "Generate PDF" the button registers the task in - * ``useCrossProviderPdfStore`` and unmounts freely; this watcher keeps polling - * every tracked generation and fires the "ready"/"failed" toast when each one - * settles — so the notification arrives even if the user has since switched to - * the overview or another framework. The toast's action links back to the - * exact page the report was generated from (where the button now offers - * "Download PDF"). + * Inner poller. Mounted by ``CrossProviderPdfWatcher`` ONLY while at least one + * generation is running, so the app-wide 3s tick exists exclusively during + * active generations — when everything has settled this component unmounts and + * the interval is torn down (rather than ticking forever over an empty set). */ -export const CrossProviderPdfWatcher = () => { +const PdfGenerationPoller = () => { const generations = useCrossProviderPdfStore((state) => state.generations); const markCompleted = useCrossProviderPdfStore( (state) => state.markCompleted, @@ -52,11 +51,12 @@ export const CrossProviderPdfWatcher = () => { const generationsRef = useRef(generations); generationsRef.current = generations; - // Per-task bookkeeping (in-flight tick + consecutive error count) that must - // NOT trigger React re-renders — a plain ref keyed by task id. - const pollStateRef = useRef>( - new Map(), - ); + // Per-task bookkeeping (in-flight tick + consecutive error count + elapsed + // ticks) that must NOT trigger React re-renders — a plain ref keyed by task + // id. + const pollStateRef = useRef< + Map + >(new Map()); // A single mount-time poller (setup on mount, cleanup on unmount). It reads // the live generations snapshot from ``generationsRef`` and the store @@ -80,9 +80,25 @@ export const CrossProviderPdfWatcher = () => { for (const generation of running) { let pollState = pollStateRef.current.get(generation.taskId); if (!pollState) { - pollState = { busy: false, errors: 0 }; + pollState = { busy: false, errors: 0, ticks: 0 }; pollStateRef.current.set(generation.taskId, pollState); } + + // Count elapsed ticks even while a poll is in flight, then give up on a + // generation that has run past the ceiling — a wedged task must not be + // polled indefinitely. + pollState.ticks += 1; + if (pollState.ticks > MAX_POLL_TICKS) { + markFailed(generation.taskId); + toast({ + variant: "destructive", + title: "PDF generation timed out", + description: + "The report is taking longer than expected. Please try generating it again.", + }); + continue; + } + // Skip a task whose previous poll (a whole-task fetch) is still in // flight — otherwise a slow tick could double-fire the ready toast. if (pollState.busy) continue; @@ -153,3 +169,31 @@ export const CrossProviderPdfWatcher = () => { return null; }; + +/** + * Single, app-wide poller for cross-provider PDF generations. + * + * Mounted once in the ``(prowler)`` layout so it survives navigation between + * views. When the user clicks "Generate PDF" the button registers the task in + * ``useCrossProviderPdfStore`` and unmounts freely; this watcher keeps polling + * every tracked generation and fires the "ready"/"failed" toast when each one + * settles — so the notification arrives even if the user has since switched to + * the overview or another framework. The toast's action links back to the + * exact page the report was generated from (where the button now offers + * "Download PDF"). + * + * The actual interval lives in ``PdfGenerationPoller``, mounted here only while + * a generation is running: instead of a bare ``useEffect`` guard inside the + * poller, conditional mounting means the app carries no ticking timer at all + * once every generation has settled. + */ +export const CrossProviderPdfWatcher = () => { + const hasRunningGeneration = useCrossProviderPdfStore((state) => + Object.values(state.generations).some( + (generation) => generation.status === "running", + ), + ); + + if (!hasRunningGeneration) return null; + return ; +}; diff --git a/ui/components/compliance/cross-provider/generate-pdf-button.tsx b/ui/components/compliance/cross-provider/generate-pdf-button.tsx index 32a8b004e9..bcec1df1d4 100644 --- a/ui/components/compliance/cross-provider/generate-pdf-button.tsx +++ b/ui/components/compliance/cross-provider/generate-pdf-button.tsx @@ -2,7 +2,7 @@ import { DownloadIcon, FileDown, Loader2 } from "lucide-react"; import type { ReactNode } from "react"; -import { useRef, useState } from "react"; +import { useState } from "react"; import type { LatestCrossProviderPdfReport } from "@/actions/compliances"; import { @@ -40,16 +40,20 @@ interface GeneratePdfButtonProps { scanIds: string[]; /** * The same ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / - * ``filter[provider_groups__in]`` values currently applied via - * ``CrossProviderFilters`` (raw comma-separated strings straight from the - * URL search params). ``scanIds`` is already narrowed by these — passing - * them too is defense-in-depth for the edge case where ``scanIds`` ends up - * empty and generation would otherwise fall back to auto-selecting across - * every compatible provider instead of respecting an active filter. + * ``filter[provider_groups__in]`` / ``filter[region__in]`` values currently + * applied to the on-screen view (raw comma-separated strings straight from + * the URL search params). ``scanIds`` is already narrowed by the provider + * filters — passing them too is defense-in-depth for the edge case where + * ``scanIds`` ends up empty and generation would otherwise fall back to + * auto-selecting across every compatible provider instead of respecting an + * active filter. ``regions`` is NOT reflected in ``scanIds`` (a scan spans + * every region), so forwarding it is what actually scopes the PDF to the + * region-narrowed view the user sees. */ providerTypes?: string; providerIds?: string; providerGroups?: string; + regions?: string; /** * A report already generated for these exact filters, resolved * server-side alongside the page's data fetch — ``null`` means none @@ -119,6 +123,7 @@ export const GeneratePdfButton = ({ providerTypes, providerIds, providerGroups, + regions, latestPdfReport, frameworkLabel, className, @@ -161,6 +166,7 @@ export const GeneratePdfButton = ({ providerTypes, providerIds, providerGroups, + regions, ].join("|"); // The invalidation only applies to the signature it happened under; once @@ -201,17 +207,14 @@ export const GeneratePdfButton = ({ serverReport ?? { taskId: "" }; const hasAvailableReport = Boolean(storeReport ?? serverReport); - // Read the live filter signature from a ref inside the async generate - // handler (refs are exempt from the hook dependency array and always hold - // the current-render value) so the store entry is tagged with the filters - // in effect at click time. - const currentSignatureRef = useRef(filterSignature); - currentSignatureRef.current = filterSignature; - const handleGenerate = async (chosenName: string) => { if (isGenerating) return; setIsStarting(true); - const signature = currentSignatureRef.current; + // ``handleGenerate`` is redefined every render, so it already closes over + // this render's ``filterSignature`` — capture it up front (before any + // await) so the store entry is tagged with the filters in effect at click + // time. No ref needed. + const signature = filterSignature; // Snapshot the page URL (path + filters) now, so the watcher's "ready" // toast can link back here even if the user has navigated elsewhere by // the time generation finishes. @@ -229,6 +232,7 @@ export const GeneratePdfButton = ({ providerTypes, providerIds, providerGroups, + regions, // Empty → server falls back to a unique timestamped default. reportName: trimmedName.length > 0 ? trimmedName : undefined, }); @@ -280,7 +284,7 @@ export const GeneratePdfButton = ({ // Generate button that never appears. removeGeneration(availableReport.taskId); setInvalidated({ - signature: currentSignatureRef.current, + signature: filterSignature, taskId: availableReport.taskId, }); toast({ diff --git a/ui/lib/compliance/cross-provider-insights.test.ts b/ui/lib/compliance/cross-provider-insights.test.ts index 5a25aa744b..1efb8d9f99 100644 --- a/ui/lib/compliance/cross-provider-insights.test.ts +++ b/ui/lib/compliance/cross-provider-insights.test.ts @@ -146,6 +146,95 @@ describe("computeCrossProviderInsights", () => { expect(byName.Other.total).toBe(1); }); + it("groups DORA-shaped requirements (Pillar, no Section) by Pillar — not Other", () => { + // DORA's universal JSON carries Pillar/Article/ArticleTitle only. The + // domain key must match the DORA mapper's ``categoryName`` (the Pillar) + // or the accordion's per-section stats lookup never hits. + const doraAttributes: CrossProviderComplianceOverviewAttributes = { + ...buildAttributes(), + compliance_id: "dora_2022_2554", + framework: "DORA", + name: "DORA", + requirements: [ + { + id: "art5", + name: "Governance", + description: "", + attributes: { + Pillar: "ICT Risk Management", + Article: "Article 5", + ArticleTitle: "Governance and organisation", + }, + status: "FAIL", + providers: { aws: "FAIL" }, + }, + { + id: "art9", + name: "Protection", + description: "", + attributes: { + Pillar: "ICT Risk Management", + Article: "Article 9", + ArticleTitle: "Protection and prevention", + }, + status: "PASS", + providers: { aws: "PASS" }, + }, + { + id: "art17", + name: "Incidents", + description: "", + attributes: { + Pillar: "ICT Incident Management", + Article: "Article 17", + ArticleTitle: "ICT-related incident management process", + }, + status: "MANUAL", + providers: {}, + }, + ], + }; + const insights = computeCrossProviderInsights(doraAttributes); + const names = insights.domainStats.map((d) => d.name).sort(); + expect(names).toEqual(["ICT Incident Management", "ICT Risk Management"]); + expect(names).not.toContain("Other"); + + const riskMgmt = insights.domainStats.find( + (d) => d.name === "ICT Risk Management", + ); + expect(riskMgmt?.total).toBe(2); + expect(riskMgmt?.fail).toBe(1); + expect(riskMgmt?.pass).toBe(1); + expect(riskMgmt?.byProvider.aws).toBe("FAIL"); + }); + + it("falls back to Other for non-string or empty Section/Pillar values", () => { + const weird: CrossProviderComplianceOverviewAttributes = { + ...buildAttributes(), + requirements: [ + { + id: "w1", + name: "numeric section", + description: "", + attributes: { Section: 3 }, + status: "PASS", + providers: { aws: "PASS" }, + }, + { + id: "w2", + name: "empty pillar", + description: "", + attributes: { Pillar: "" }, + status: "FAIL", + providers: { aws: "FAIL" }, + }, + ], + }; + const insights = computeCrossProviderInsights(weird); + expect(insights.domainStats.map((d) => d.name)).toEqual(["Other"]); + expect(insights.domainStats[0].total).toBe(2); + }); + it("rolls each domain's per-provider status with FAIL > PASS > MANUAL > NO_ROW", () => { const insights = computeCrossProviderInsights(buildAttributes()); const audit = insights.domainStats.find((d) => d.name === "Audit"); diff --git a/ui/lib/compliance/cross-provider-insights.ts b/ui/lib/compliance/cross-provider-insights.ts index 860172938e..cf5b484ed6 100644 --- a/ui/lib/compliance/cross-provider-insights.ts +++ b/ui/lib/compliance/cross-provider-insights.ts @@ -120,9 +120,11 @@ export const computeCrossProviderInsights = ( const providerFail = new Map(); const providerTotal = new Map(); - // Domain accumulators are keyed by section name. We only know the - // section once we read ``req.attributes.Section`` — fall back to a - // generic bucket so requirements without the field still surface + // Domain accumulators are keyed by domain name. The key must match the + // ``categoryName`` each framework's mapper derives (the accordion looks up + // these stats by that name): CSA-CCM and CIS-Controls group by + // ``attributes.Section``, DORA by ``attributes.Pillar``. Fall back to a + // generic bucket so requirements without either field still surface // somewhere instead of silently dropping out. const domainAcc = new Map< string, @@ -136,8 +138,12 @@ export const computeCrossProviderInsights = ( >(); for (const req of requirements) { + const attrs = req.attributes as + | { Section?: unknown; Pillar?: unknown } + | undefined; + const rawDomain = attrs?.Section ?? attrs?.Pillar; const section = - (req.attributes as { Section?: string } | undefined)?.Section || "Other"; + typeof rawDomain === "string" && rawDomain !== "" ? rawDomain : "Other"; let domain = domainAcc.get(section); if (!domain) { domain = { diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 2be6f0132b..e2b12e2304 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -98,14 +98,10 @@ module.exports = { collapse: "collapse 400ms linear 0s 1 normal forwards running", }, keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, + // ``accordion-down`` / ``accordion-up`` are defined once, in + // ``styles/globals.css`` (alongside the Tailwind 4 ``--animate-*`` + // tokens that reference them). They used to be duplicated here but + // nothing referenced this copy, so it lived as a silent second source. "collapsible-down": { from: { height: "0" }, to: { height: "var(--radix-collapsible-content-height)" },