feat(ui): add provider filtering to the compliance overview

- Mount provider type/account and provider group selectors in the
  compliance filter bar; selecting a scan or provider filter clears the
  other (XOR)
- Aggregate frameworks across the latest scan per matching provider;
  hide the ThreatScore badge while provider filters are active
- Hide the per-scan CIS PDF and carry provider filters into the
  card drill-down in aggregated mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pablo F.G
2026-06-22 10:13:07 +02:00
parent 9b4d5b01ed
commit fd0c875065
7 changed files with 320 additions and 57 deletions
+11
View File
@@ -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={");
});
});
+57 -25
View File
@@ -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 ? (
<>
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
uniqueRegions={uniqueRegions}
selectedScanId={selectedScanId}
providers={providersData?.data ?? []}
providerGroups={providerGroupsData?.data ?? []}
/>
</div>
@@ -182,6 +203,8 @@ export default async function Compliance({
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
hasProviderFilters={hasProviderFilters}
providerFilters={providerFilters}
/>
</Suspense>
</>
@@ -196,15 +219,23 @@ const SSRComplianceGrid = async ({
searchParams,
scanId,
selectedScan,
hasProviderFilters,
providerFilters,
}: {
searchParams: SearchParamsProps;
scanId: string | null;
selectedScan?: ScanEntity;
hasProviderFilters: boolean;
providerFilters: Record<string, string>;
}) => {
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 ({
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
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."}
</AlertDescription>
</Alert>
);
@@ -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("<ComplianceDownloadContainer");
});
it("carries provider filters (not scanId) into the drill-down when aggregated", () => {
expect(source).toContain(
"providerFilters: isAggregated ? providerFilters : undefined",
);
expect(source).toContain("scanId: isAggregated ? null : scanId");
});
});
+38 -27
View File
@@ -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<ComplianceCardProps> = ({
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<ComplianceCardProps> = ({
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<ComplianceCardProps> = ({
className="relative cursor-pointer transition-shadow hover:shadow-md"
onClick={navigateToDetail}
>
<div
className="absolute top-2 right-2 z-10"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
role="group"
tabIndex={0}
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
/>
</div>
{!isAggregated && (
<div
className="absolute top-2 right-2 z-10"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
role="group"
tabIndex={0}
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
/>
</div>
)}
<CardContent className="p-0">
<div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-3 pr-9">
@@ -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;
}) => (
<button
data-testid="scan-selector"
onClick={() => onSelectionChange("scan-2")}
/>
),
}));
vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: ({
paramsToDeleteOnChange,
}: {
paramsToDeleteOnChange?: string[];
}) => (
<div
data-testid="provider-account-selectors"
data-params={(paramsToDeleteOnChange ?? []).join(",")}
/>
),
}));
vi.mock(
"@/app/(prowler)/_overview/_components/provider-group-selector",
() => ({
ProviderGroupSelector: ({
paramsToDeleteOnChange,
}: {
paramsToDeleteOnChange?: string[];
}) => (
<div
data-testid="provider-group-selector"
data-params={(paramsToDeleteOnChange ?? []).join(",")}
/>
),
}),
);
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <div data-testid="clear-filters" />,
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({ children }: { children: React.ReactNode }) => (
<div data-testid="region-multiselect">{children}</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectItem: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectSeparator: () => <hr />,
}));
const defaultProps = {
scans: [],
uniqueRegions: ["eu-west-1"],
selectedScanId: "scan-1",
providers: [],
providerGroups: [],
};
beforeEach(() => {
vi.clearAllMocks();
currentSearchParams = new URLSearchParams();
});
describe("ComplianceFilters", () => {
it("renders the scan, provider type/account, provider group and region selectors", () => {
render(<ComplianceFilters {...defaultProps} />);
expect(screen.getByTestId("scan-selector")).toBeInTheDocument();
expect(
screen.getByTestId("provider-account-selectors"),
).toBeInTheDocument();
expect(screen.getByTestId("provider-group-selector")).toBeInTheDocument();
expect(screen.getByTestId("region-multiselect")).toBeInTheDocument();
});
it("wires the provider selectors to clear scanId + page on change (reverse XOR)", () => {
render(<ComplianceFilters {...defaultProps} />);
for (const testId of [
"provider-account-selectors",
"provider-group-selector",
]) {
const params = screen.getByTestId(testId).getAttribute("data-params");
expect(params).toContain("scanId");
expect(params).toContain("page");
}
});
it("clears provider-scope filters and page when a scan is selected", () => {
currentSearchParams = new URLSearchParams(
"scanId=scan-1&filter[provider_type__in]=aws&filter[provider_id__in]=p1&filter[provider_groups__in]=g1&filter[region__in]=eu-west-1&page=2",
);
render(<ComplianceFilters {...defaultProps} />);
fireEvent.click(screen.getByTestId("scan-selector"));
expect(pushMock).toHaveBeenCalledTimes(1);
const pushedUrl = new URL(
pushMock.mock.calls[0][0] as string,
"https://example.com",
);
const params = pushedUrl.searchParams;
expect(params.get("scanId")).toBe("scan-2");
expect(params.get("filter[provider_type__in]")).toBeNull();
expect(params.get("filter[provider_id__in]")).toBeNull();
expect(params.get("filter[provider_groups__in]")).toBeNull();
expect(params.get("page")).toBeNull();
// region is independent of the scan/provider XOR and must survive
expect(params.get("filter[region__in]")).toBe("eu-west-1");
});
});
@@ -2,7 +2,9 @@
import { useRouter, useSearchParams } from "next/navigation";
import { ProviderGroupSelector } from "@/app/(prowler)/_overview/_components/provider-group-selector";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import {
MultiSelect,
MultiSelectContent,
@@ -13,27 +15,43 @@ import {
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { COMPLIANCE_PROVIDER_FILTER_KEYS } from "@/lib/compliance/compliance-provider-filters";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import { ScanSelector, SelectScanComplianceDataProps } from "./scan-selector";
// Clearing scanId/page is the inverse of the provider selectors'
// paramsToDeleteOnChange — together they enforce the backend scan_id ⊕ provider XOR.
const PROVIDER_PARAMS_TO_DELETE = ["scanId", "page"];
const SELECTOR_WIDTH = "w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1";
interface ComplianceFiltersProps {
scans: SelectScanComplianceDataProps["scans"];
uniqueRegions: string[];
selectedScanId: string;
/** Null in aggregated mode (provider filters drive the scope, no single scan). */
selectedScanId: string | null;
providers: ProviderProps[];
providerGroups: ProviderGroup[];
}
export const ComplianceFilters = ({
scans,
uniqueRegions,
selectedScanId,
providers,
providerGroups,
}: ComplianceFiltersProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
// XOR: choosing a single scan clears any active provider-scope filters.
const handleScanChange = (selectedKey: string) => {
const params = new URLSearchParams(searchParams);
params.set("scanId", selectedKey);
COMPLIANCE_PROVIDER_FILTER_KEYS.forEach((key) => params.delete(key));
params.delete("page");
router.push(`?${params.toString()}`, { scroll: false });
};
@@ -41,16 +59,31 @@ export const ComplianceFilters = ({
searchParams.get("filter[region__in]")?.split(",").filter(Boolean) ?? [];
return (
<div className="flex max-w-4xl flex-wrap items-center gap-4">
<div className="flex flex-wrap items-center gap-4">
<div className="w-full sm:max-w-[380px] sm:min-w-[200px] sm:flex-1">
<ScanSelector
scans={scans}
selectedScanId={selectedScanId}
selectedScanId={selectedScanId ?? ""}
onSelectionChange={handleScanChange}
/>
</div>
{/* Provider-scope filters: selecting any switches to aggregated mode and clears scanId. */}
<ProviderAccountSelectors
providers={providers}
accountFilterKey="provider_id__in"
accountValue="id"
paramsToDeleteOnChange={PROVIDER_PARAMS_TO_DELETE}
providerSelectorClassName={SELECTOR_WIDTH}
accountSelectorClassName={SELECTOR_WIDTH}
/>
<div className={SELECTOR_WIDTH}>
<ProviderGroupSelector
groups={providerGroups}
paramsToDeleteOnChange={PROVIDER_PARAMS_TO_DELETE}
/>
</div>
{uniqueRegions.length > 0 && (
<div className="w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1">
<div className={SELECTOR_WIDTH}>
<MultiSelect
values={regionValues}
onValuesChange={(values) => updateFilter("region__in", values)}
@@ -7,6 +7,7 @@ import { ComplianceCard } from "@/components/compliance/compliance-card";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTableSearch } from "@/components/ui/table/data-table-search";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters";
import { getFlowById } from "@/lib/onboarding";
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
import type { ComplianceOverviewData } from "@/types/compliance";
@@ -43,6 +44,12 @@ export const ComplianceOverviewGrid = ({
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState("");
// Aggregated mode: provider filters in the URL replace the single-scan scope.
const providerFilters = extractComplianceProviderFilters(
new URLSearchParams(searchParams.toString()),
);
const isAggregated = Object.keys(providerFilters).length > 0;
const filteredFrameworks = frameworks.filter((compliance) =>
compliance.attributes.framework
.toLowerCase()
@@ -62,8 +69,9 @@ export const ComplianceOverviewGrid = ({
title: first.attributes.framework,
complianceId: first.id,
version: first.attributes.version,
scanId,
scanId: isAggregated ? null : scanId,
regionFilter: searchParams.get("filter[region__in]"),
providerFilters: isAggregated ? providerFilters : undefined,
}),
);
};