diff --git a/ui/app/(prowler)/compliance/page.test.tsx b/ui/app/(prowler)/compliance/page.test.tsx index 42bbbe672f..e149dd5ade 100644 --- a/ui/app/(prowler)/compliance/page.test.tsx +++ b/ui/app/(prowler)/compliance/page.test.tsx @@ -13,4 +13,15 @@ describe("Compliance overview page", () => { expect(source).toContain("ComplianceOverviewGrid"); expect(source).not.toContain("filter[search]"); }); + + it("switches to aggregated mode when provider filters are present", () => { + expect(source).toContain("hasComplianceProviderFilters"); + expect(source).toContain("extractComplianceProviderFilters"); + }); + + it("feeds the provider and provider-group selectors", () => { + expect(source).toContain("getAllProviders"); + expect(source).toContain("getAllProviderGroups"); + expect(source).toContain("providerGroups={"); + }); }); diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index d4ee609a0c..5334214c6d 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -5,7 +5,9 @@ import { getComplianceOverviewMetadataInfo, getCompliancesOverview, } from "@/actions/compliances"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getThreatScore } from "@/actions/overview"; +import { getAllProviders } from "@/actions/providers/providers"; import { getScans } from "@/actions/scans"; import { ComplianceSkeletonGrid, @@ -17,6 +19,10 @@ import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overv import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { Card, CardContent } from "@/components/shadcn/card/card"; import { ContentLayout } from "@/components/ui"; +import { + extractComplianceProviderFilters, + hasComplianceProviderFilters, +} from "@/lib/compliance/compliance-provider-filters"; import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types"; import { ExpandedScanData, @@ -34,16 +40,24 @@ export default async function Compliance({ const resolvedSearchParams = await searchParams; const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); - const scansData = await getScans({ - filters: { - "filter[state]": "completed", - }, - pageSize: 50, - fields: { - scans: "name,completed_at,provider", - }, - include: "provider", - }); + const hasProviderFilters = hasComplianceProviderFilters(resolvedSearchParams); + const providerFilters = + extractComplianceProviderFilters(resolvedSearchParams); + + const [scansData, providersData, providerGroupsData] = await Promise.all([ + getScans({ + filters: { + "filter[state]": "completed", + }, + pageSize: 50, + fields: { + scans: "name,completed_at,provider", + }, + include: "provider", + }), + getAllProviders(), + getAllProviderGroups(), + ]); if (!scansData?.data) { return ( @@ -90,9 +104,12 @@ export default async function Compliance({ const scanIdFromUrl = Array.isArray(scanIdParam) ? scanIdParam[0] : scanIdParam; - const selectedScanId: string | null = - scanIdFromUrl || expandedScansData[0]?.id || null; - const onboardingAction = selectedScanId + // Aggregated mode ignores scanId entirely (backend XOR); provider filters drive scope. + const selectedScanId: string | null = hasProviderFilters + ? null + : scanIdFromUrl || expandedScansData[0]?.id || null; + const hasScope = hasProviderFilters || Boolean(selectedScanId); + const onboardingAction = hasScope ? { flowId: "view-compliance" } : { flowId: "view-compliance", @@ -115,13 +132,15 @@ export default async function Compliance({ } : undefined; - const metadataInfoData = selectedScanId - ? await getComplianceOverviewMetadataInfo({ - filters: { - "filter[scan_id]": selectedScanId, - }, - }) - : { data: { attributes: { regions: [] } } }; + const metadataInfoData = hasProviderFilters + ? await getComplianceOverviewMetadataInfo({ filters: providerFilters }) + : selectedScanId + ? await getComplianceOverviewMetadataInfo({ + filters: { + "filter[scan_id]": selectedScanId, + }, + }) + : { data: { attributes: { regions: [] } } }; const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; @@ -146,13 +165,15 @@ export default async function Compliance({ icon="lucide:shield-check" onboardingAction={onboardingAction} > - {selectedScanId ? ( + {hasScope ? ( <>
@@ -182,6 +203,8 @@ export default async function Compliance({ searchParams={resolvedSearchParams} scanId={selectedScanId} selectedScan={selectedScanData} + hasProviderFilters={hasProviderFilters} + providerFilters={providerFilters} /> @@ -196,15 +219,23 @@ const SSRComplianceGrid = async ({ searchParams, scanId, selectedScan, + hasProviderFilters, + providerFilters, }: { searchParams: SearchParamsProps; scanId: string | null; selectedScan?: ScanEntity; + hasProviderFilters: boolean; + providerFilters: Record; }) => { const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; - const compliancesData = - scanId && scanId.trim() !== "" + const compliancesData = hasProviderFilters + ? await getCompliancesOverview({ + region: regionFilter, + filters: providerFilters, + }) + : scanId && scanId.trim() !== "" ? await getCompliancesOverview({ scanId, region: regionFilter, @@ -230,8 +261,9 @@ const SSRComplianceGrid = async ({ - This scan has no compliance data available yet, please select a - different one. + {hasProviderFilters + ? "No completed scans match the selected providers." + : "This scan has no compliance data available yet, please select a different one."} ); diff --git a/ui/components/compliance/compliance-card.test.tsx b/ui/components/compliance/compliance-card.test.tsx index 996c8cb979..b5a24b9cc2 100644 --- a/ui/components/compliance/compliance-card.test.tsx +++ b/ui/components/compliance/compliance-card.test.tsx @@ -27,4 +27,21 @@ describe("ComplianceCard", () => { expect(source).toContain('orientation="column"'); expect(source).toContain('buttonWidth="icon"'); }); + + it("derives aggregated mode from the provider filters in the URL", () => { + expect(source).toContain("extractComplianceProviderFilters"); + expect(source).toContain("const isAggregated ="); + }); + + it("hides the per-scan PDF download in aggregated mode", () => { + expect(source).toContain("{!isAggregated && ("); + expect(source).toContain(" { + expect(source).toContain( + "providerFilters: isAggregated ? providerFilters : undefined", + ); + expect(source).toContain("scanId: isAggregated ? null : scanId"); + }); }); diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 3972bb8db4..4546d9f13c 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -11,6 +11,7 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url"; +import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters"; import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types"; import { getScoreIndicatorClass, @@ -55,6 +56,13 @@ export const ComplianceCard: React.FC = ({ const router = useRouter(); const hasRegionFilter = searchParams.has("filter[region__in]"); + // Aggregated mode: provider filters replace the single-scan scope, so per-scan + // affordances (CIS PDF) are hidden and the drill-down carries provider filters. + const providerFilters = extractComplianceProviderFilters( + new URLSearchParams(searchParams.toString()), + ); + const isAggregated = Object.keys(providerFilters).length > 0; + const formatTitle = (title: string) => { return title.split("-").join(" "); }; @@ -75,8 +83,9 @@ export const ComplianceCard: React.FC = ({ title, complianceId: id, version, - scanId, + scanId: isAggregated ? null : scanId, regionFilter: searchParams.get("filter[region__in]"), + providerFilters: isAggregated ? providerFilters : undefined, }), ); }; @@ -88,32 +97,34 @@ export const ComplianceCard: React.FC = ({ className="relative cursor-pointer transition-shadow hover:shadow-md" onClick={navigateToDetail} > -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.stopPropagation(); - } - }} - role="group" - tabIndex={0} - > - -
+ {!isAggregated && ( +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } + }} + role="group" + tabIndex={0} + > + +
+ )}
diff --git a/ui/components/compliance/compliance-header/compliance-filters.test.tsx b/ui/components/compliance/compliance-header/compliance-filters.test.tsx new file mode 100644 index 0000000000..88f39e9ea3 --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-filters.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ComplianceFilters } from "./compliance-filters"; + +const { pushMock, updateFilterMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + updateFilterMock: vi.fn(), +})); + +let currentSearchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), + useSearchParams: () => currentSearchParams, +})); + +vi.mock("@/hooks/use-url-filters", () => ({ + useUrlFilters: () => ({ updateFilter: updateFilterMock }), +})); + +vi.mock("./scan-selector", () => ({ + ScanSelector: ({ + onSelectionChange, + }: { + onSelectionChange: (key: string) => void; + }) => ( +