refactor(ui): redesign compliance page layout and components (#10767)

This commit is contained in:
Alejandro Bailo
2026-04-21 12:48:57 +02:00
committed by GitHub
parent ac6dd03fb8
commit fc3066bc60
27 changed files with 862 additions and 311 deletions
+3 -2
View File
@@ -4,9 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.25.0] (Prowler UNRELEASED)
### ❌ Removed
### 🔄 Changed
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly
- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
---
+4 -7
View File
@@ -6,12 +6,10 @@ import { handleApiResponse } from "@/lib/server-actions-helper";
export const getCompliancesOverview = async ({
scanId,
region,
query,
filters = {},
}: {
scanId?: string;
region?: string | string[];
query?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -31,8 +29,6 @@ export const getCompliancesOverview = async ({
setParam("filter[scan_id]", scanId);
setParam("filter[region__in]", region);
if (query) url.searchParams.set("filter[search]", query);
try {
const response = await fetch(url.toString(), {
headers,
@@ -46,15 +42,16 @@ export const getCompliancesOverview = async ({
};
export const getComplianceOverviewMetadataInfo = async ({
query = "",
sort = "",
filters = {},
}) => {
}: {
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/compliance-overviews/metadata`);
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
@@ -78,7 +78,7 @@ export default async function ComplianceDetail({
await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
"filter[scan_id]": selectedScanId ?? undefined,
},
}),
getComplianceAttributes(complianceId),
+16
View File
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("Compliance overview page", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "page.tsx");
const source = readFileSync(filePath, "utf8");
it("delegates client-side search to ComplianceOverviewGrid", () => {
expect(source).toContain("ComplianceOverviewGrid");
expect(source).not.toContain("filter[search]");
});
});
+84 -85
View File
@@ -1,3 +1,4 @@
import { Info } from "lucide-react";
import { Suspense } from "react";
import {
@@ -7,12 +8,14 @@ import {
import { getThreatScore } from "@/actions/overview";
import { getScans } from "@/actions/scans";
import {
ComplianceCard,
ComplianceSkeletonGrid,
NoScansAvailable,
ThreatScoreBadge,
} from "@/components/compliance";
import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
import { ComplianceFilters } from "@/components/compliance/compliance-header/compliance-filters";
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/ui";
import {
ExpandedScanData,
@@ -30,12 +33,6 @@ export default async function Compliance({
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
const filters = Object.fromEntries(
Object.entries(resolvedSearchParams).filter(([key]) =>
key.startsWith("filter["),
),
);
const scansData = await getScans({
filters: {
"filter[state]": "completed",
@@ -79,9 +76,12 @@ export default async function Compliance({
.filter(Boolean) as ExpandedScanData[];
// Use scanId from URL, or select the first scan if not provided
const selectedScanId =
resolvedSearchParams.scanId || expandedScansData[0]?.id || null;
const query = (filters["filter[search]"] as string) || "";
const scanIdParam = resolvedSearchParams.scanId;
const scanIdFromUrl = Array.isArray(scanIdParam)
? scanIdParam[0]
: scanIdParam;
const selectedScanId: string | null =
scanIdFromUrl || expandedScansData[0]?.id || null;
// Find the selected scan
const selectedScan = expandedScansData.find(
@@ -102,7 +102,6 @@ export default async function Compliance({
// Fetch metadata if we have a selected scan
const metadataInfoData = selectedScanId
? await getComplianceOverviewMetadataInfo({
query,
filters: {
"filter[scan_id]": selectedScanId,
},
@@ -131,28 +130,39 @@ export default async function Compliance({
<ContentLayout title="Compliance" icon="lucide:shield-check">
{selectedScanId ? (
<>
<div className="mb-6 flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<ComplianceHeader
scans={expandedScansData}
uniqueRegions={uniqueRegions}
/>
</div>
{threatScoreData &&
typeof selectedScanId === "string" &&
selectedScan && (
<div className="w-full lg:w-[360px] lg:flex-shrink-0">
<ThreatScoreBadge
score={threatScoreData.score}
scanId={selectedScanId}
provider={selectedScan.providerInfo.provider}
selectedScan={selectedScanData}
sectionScores={threatScoreData.sectionScores}
/>
</div>
)}
{/* Row 1: Filters */}
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
uniqueRegions={uniqueRegions}
selectedScanId={selectedScanId}
/>
</div>
<Suspense key={searchParamsKey} fallback={<ComplianceSkeletonGrid />}>
{/* Row 2: ThreatScore card — full width, horizontal */}
{threatScoreData &&
typeof selectedScanId === "string" &&
selectedScan && (
<div className="mb-6">
<ThreatScoreBadge
score={threatScoreData.score}
scanId={selectedScanId}
provider={selectedScan.providerInfo.provider}
selectedScan={selectedScanData}
sectionScores={threatScoreData.sectionScores}
/>
</div>
)}
{/* Row 3: Compliance grid with client-side search */}
<Suspense
key={searchParamsKey}
fallback={
<ComplianceOverviewPanel>
<ComplianceSkeletonGrid />
</ComplianceOverviewPanel>
}
>
<SSRComplianceGrid
searchParams={resolvedSearchParams}
selectedScan={selectedScanData}
@@ -176,25 +186,23 @@ const SSRComplianceGrid = async ({
const scanId = searchParams.scanId?.toString() || "";
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
// Extract all filter parameters
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
// Only fetch compliance data if we have a valid scanId
const compliancesData =
scanId && scanId.trim() !== ""
? await getCompliancesOverview({
scanId,
region: regionFilter,
query,
})
: { data: [], errors: [] };
const type = compliancesData?.data?.type;
const frameworks = compliancesData?.data
?.filter((compliance: ComplianceOverviewData) => {
return compliance.attributes.framework !== "ProwlerThreatScore";
})
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
a.attributes.framework.localeCompare(b.attributes.framework),
);
// Check if the response contains no data
if (
@@ -204,58 +212,49 @@ const SSRComplianceGrid = async ({
type === "tasks"
) {
return (
<div className="flex h-full items-center">
<div className="text-default-500 text-sm">
No compliance data available for the selected scan.
</div>
</div>
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
This scan has no compliance data available yet, please select a
different one.
</AlertDescription>
</Alert>
);
}
// Handle errors returned by the API
if (compliancesData?.errors?.length > 0) {
return (
<div className="flex h-full items-center">
<div className="text-default-500 text-sm">Provide a valid scan ID.</div>
</div>
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>Provide a valid scan ID.</AlertDescription>
</Alert>
);
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{compliancesData.data
.filter((compliance: ComplianceOverviewData) => {
// Filter out ProwlerThreatScore from the grid
return compliance.attributes.framework !== "ProwlerThreatScore";
})
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
a.attributes.framework.localeCompare(b.attributes.framework),
)
.map((compliance: ComplianceOverviewData) => {
const { attributes, id } = compliance;
const {
framework,
version,
requirements_passed,
total_requirements,
} = attributes;
return (
<ComplianceCard
key={id}
title={framework}
version={version}
passingRequirements={requirements_passed}
totalRequirements={total_requirements}
prevPassingRequirements={requirements_passed}
prevTotalRequirements={total_requirements}
scanId={scanId}
complianceId={id}
id={id}
selectedScan={selectedScan}
/>
);
})}
</div>
<ComplianceOverviewPanel>
<ComplianceOverviewGrid
frameworks={frameworks}
scanId={scanId}
selectedScan={selectedScan}
/>
</ComplianceOverviewPanel>
);
};
const ComplianceOverviewPanel = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<Card
variant="base"
padding="none"
className="minimal-scrollbar shadow-small relative z-0 w-full gap-4 overflow-auto"
>
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
</Card>
);
};
@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("ComplianceCard", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "compliance-card.tsx");
const source = readFileSync(filePath, "utf8");
it("keeps the shadcn Card base variant", () => {
expect(source).toContain('variant="base"');
});
it("uses a responsive stacked layout for narrow screens", () => {
expect(source).toContain("flex-col");
expect(source).toContain("sm:flex-row");
});
it("uses the shadcn progress component instead of Hero UI", () => {
expect(source).toContain('from "@/components/shadcn/progress"');
expect(source).not.toContain("@heroui/progress");
});
it("places compact actions in the icon column on larger screens", () => {
expect(source).toContain('orientation="column"');
expect(source).toContain('buttonWidth="icon"');
});
});
+77 -54
View File
@@ -1,11 +1,20 @@
"use client";
import { Progress } from "@heroui/progress";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { Progress } from "@/components/shadcn/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types";
import {
getScoreIndicatorClass,
type ScoreColorVariant,
} from "@/lib/compliance/score-utils";
import { ScanEntity } from "@/types/scans";
import { getComplianceIcon } from "../icons";
@@ -45,13 +54,9 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
(passingRequirements / totalRequirements) * 100,
);
const getRatingColor = (ratingPercentage: number) => {
if (ratingPercentage <= 10) {
return "danger";
}
if (ratingPercentage <= 40) {
return "warning";
}
const getRatingVariant = (value: number): ScoreColorVariant => {
if (value <= 10) return "danger";
if (value <= 40) return "warning";
return "success";
};
@@ -80,58 +85,76 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
onClick={navigateToDetail}
>
<CardContent className="p-0">
<div className="flex w-full items-center gap-4">
{getComplianceIcon(title) && (
<Image
src={getComplianceIcon(title)}
alt={`${title} logo`}
className="h-10 w-10 min-w-10 rounded-md border border-gray-300 bg-white object-contain p-1"
/>
)}
<div className="flex w-full flex-col">
<h4 className="text-small mb-1 leading-5 font-bold">
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</h4>
<Progress
label="Score:"
size="sm"
aria-label="Compliance score"
value={ratingPercentage}
showValueLabel={true}
classNames={{
track: "drop-shadow-sm border border-default",
label: "tracking-wider font-medium text-default-600 text-xs",
value: "text-foreground/60 -mb-2",
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-start">
<div className="flex shrink-0 items-center justify-between sm:flex-col sm:items-start sm:gap-2">
{getComplianceIcon(title) && (
<Image
src={getComplianceIcon(title)}
alt={`${title} logo`}
className="h-10 w-10 min-w-10 self-start rounded-md border border-gray-300 bg-white object-contain p-1"
/>
)}
<div
className="shrink-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
color={getRatingColor(ratingPercentage)}
/>
<div className="mt-2 flex items-center justify-between">
<small>
role="group"
tabIndex={0}
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForFramework(title)}
disabled={hasRegionFilter}
/>
</div>
</div>
<div className="flex w-full min-w-0 flex-col gap-3">
<Tooltip>
<TooltipTrigger asChild>
<h4 className="text-small truncate leading-5 font-bold">
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</h4>
</TooltipTrigger>
<TooltipContent>
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</TooltipContent>
</Tooltip>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-text-neutral-secondary font-medium tracking-wider">
Score:
</span>
<span className="text-text-neutral-secondary">
{ratingPercentage}%
</span>
</div>
<Progress
aria-label="Compliance score"
value={ratingPercentage}
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
indicatorClassName={getScoreIndicatorClass(
getRatingVariant(ratingPercentage),
)}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<small className="min-w-0">
<span className="mr-1 text-xs font-semibold">
{passingRequirements} / {totalRequirements}
</span>
Passing Requirements
</small>
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
role="group"
tabIndex={0}
>
<ComplianceDownloadContainer
compact
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForFramework(title)}
disabled={hasRegionFilter}
/>
</div>
</div>
</div>
</div>
@@ -0,0 +1,133 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { downloadComplianceCsvMock, downloadComplianceReportPdfMock } =
vi.hoisted(() => ({
downloadComplianceCsvMock: vi.fn(),
downloadComplianceReportPdfMock: vi.fn(),
}));
vi.mock("@/lib/helper", () => ({
downloadComplianceCsv: downloadComplianceCsvMock,
downloadComplianceReportPdf: downloadComplianceReportPdfMock,
}));
vi.mock("@/components/ui", () => ({
toast: {},
}));
import { ComplianceDownloadContainer } from "./compliance-download-container";
describe("ComplianceDownloadContainer", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "compliance-download-container.tsx");
const source = readFileSync(filePath, "utf8");
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the shared action dropdown for the card actions mode", () => {
expect(source).toContain("ActionDropdown");
expect(source).not.toContain("@heroui/button");
});
it("should expose an accessible actions menu trigger", () => {
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId="compliance-1"
reportType="threatscore"
/>,
);
expect(
screen.getByRole("button", { name: "Open compliance export actions" }),
).toBeInTheDocument();
});
it("should support fixed icon-sized dropdown trigger in column mode", () => {
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
orientation="column"
buttonWidth="icon"
scanId="scan-1"
complianceId="compliance-1"
reportType="threatscore"
/>,
);
const trigger = screen.getByRole("button", {
name: "Open compliance export actions",
});
expect(trigger.className).toContain("border-text-neutral-secondary");
});
it("should open export actions from the compact trigger", async () => {
const user = userEvent.setup();
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId="compliance-1"
reportType="threatscore"
/>,
);
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
expect(screen.getByText("Download CSV report")).toBeInTheDocument();
expect(screen.getByText("Download PDF report")).toBeInTheDocument();
});
it("should trigger both downloads from the actions menu", async () => {
const user = userEvent.setup();
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId="compliance-1"
reportType="threatscore"
/>,
);
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
await user.click(
screen.getByRole("menuitem", { name: /Download CSV report/i }),
);
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
await user.click(
screen.getByRole("menuitem", { name: /Download PDF report/i }),
);
expect(downloadComplianceCsvMock).toHaveBeenCalledWith(
"scan-1",
"compliance-1",
{},
);
expect(downloadComplianceReportPdfMock).toHaveBeenCalledWith(
"scan-1",
"threatscore",
{},
);
});
});
@@ -4,6 +4,15 @@ import { DownloadIcon, FileTextIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { toast } from "@/components/ui";
import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types";
import {
@@ -18,6 +27,9 @@ interface ComplianceDownloadContainerProps {
reportType?: ComplianceReportType;
compact?: boolean;
disabled?: boolean;
orientation?: "row" | "column";
buttonWidth?: "auto" | "icon";
presentation?: "buttons" | "dropdown";
}
export const ComplianceDownloadContainer = ({
@@ -26,9 +38,14 @@ export const ComplianceDownloadContainer = ({
reportType,
compact = false,
disabled = false,
orientation = "row",
buttonWidth = "auto",
presentation = "buttons",
}: ComplianceDownloadContainerProps) => {
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
const isIconWidth = buttonWidth === "icon";
const isDropdown = presentation === "dropdown";
const handleDownloadCsv = async () => {
if (isDownloadingCsv) return;
@@ -52,40 +69,116 @@ export const ComplianceDownloadContainer = ({
const buttonClassName = cn(
"border-button-primary text-button-primary hover:bg-button-primary/10",
compact && "h-7 px-2 text-xs",
compact &&
!isIconWidth &&
"h-7 px-2 text-xs sm:w-full sm:justify-center sm:px-2.5",
orientation === "column" && !isIconWidth && "w-full",
isIconWidth && "size-10 rounded-lg p-0",
);
const labelClassName = isIconWidth
? "sr-only"
: compact
? "sr-only sm:not-sr-only"
: undefined;
const showTooltip = compact || isIconWidth;
return (
<div className={cn("flex gap-2", compact ? "items-center" : "flex-col")}>
<Button
size="sm"
variant="outline"
className={buttonClassName}
onClick={handleDownloadCsv}
disabled={disabled || isDownloadingCsv}
aria-label="Download compliance CSV report"
>
<FileTextIcon
size={14}
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
CSV
</Button>
{reportType && (
<Button
size="sm"
variant="outline"
className={buttonClassName}
onClick={handleDownloadPdf}
disabled={disabled || isDownloadingPdf}
aria-label="Download compliance PDF report"
<div
className={cn(
"flex",
orientation === "column"
? "flex-col items-start"
: compact
? "w-full justify-end sm:w-auto"
: "flex-row",
)}
>
{isDropdown ? (
<ActionDropdown
variant={isIconWidth ? "bordered" : "table"}
ariaLabel="Open compliance export actions"
>
<DownloadIcon
size={14}
className={isDownloadingPdf ? "animate-download-icon" : ""}
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
disabled={disabled || isDownloadingCsv}
/>
PDF
</Button>
{reportType && (
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
disabled={disabled || isDownloadingPdf}
/>
)}
</ActionDropdown>
) : (
<div
className={cn(
"flex gap-2",
orientation === "column"
? isIconWidth
? "flex-col items-start"
: "flex-col items-stretch"
: compact
? "w-full flex-wrap items-center justify-end sm:w-auto sm:flex-nowrap"
: "flex-row flex-wrap items-center",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className={buttonClassName}
onClick={handleDownloadCsv}
disabled={disabled || isDownloadingCsv}
aria-label="Download compliance CSV report"
>
<FileTextIcon
size={14}
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
<span className={labelClassName}>CSV</span>
</Button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent>Download CSV report</TooltipContent>
)}
</Tooltip>
{reportType && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className={buttonClassName}
onClick={handleDownloadPdf}
disabled={disabled || isDownloadingPdf}
aria-label="Download compliance PDF report"
>
<DownloadIcon
size={14}
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
<span className={labelClassName}>PDF</span>
</Button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent>Download PDF report</TooltipContent>
)}
</Tooltip>
)}
</div>
)}
</div>
);
@@ -0,0 +1,76 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectSelectAll,
MultiSelectSeparator,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { ScanSelector, SelectScanComplianceDataProps } from "./scan-selector";
interface ComplianceFiltersProps {
scans: SelectScanComplianceDataProps["scans"];
uniqueRegions: string[];
selectedScanId: string;
}
export const ComplianceFilters = ({
scans,
uniqueRegions,
selectedScanId,
}: ComplianceFiltersProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const handleScanChange = (selectedKey: string) => {
const params = new URLSearchParams(searchParams);
params.set("scanId", selectedKey);
router.push(`?${params.toString()}`, { scroll: false });
};
const regionValues =
searchParams.get("filter[region__in]")?.split(",").filter(Boolean) ?? [];
return (
<div className="flex max-w-4xl 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}
onSelectionChange={handleScanChange}
/>
</div>
{uniqueRegions.length > 0 && (
<div className="w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1">
<MultiSelect
values={regionValues}
onValuesChange={(values) => updateFilter("region__in", values)}
>
<MultiSelectTrigger size="default">
<MultiSelectValue placeholder="All Regions" />
</MultiSelectTrigger>
<MultiSelectContent search={false} width="wide">
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{uniqueRegions.map((region) => (
<MultiSelectItem key={region} value={region}>
{region}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>
</div>
)}
<ClearFiltersButton showCount />
</div>
);
};
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("ComplianceHeader", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "compliance-header.tsx");
const source = readFileSync(filePath, "utf8");
it("renders the scan selector inside the shared filters grid using default layout", () => {
expect(source).toContain("prependElement");
expect(source).toContain("<DataCompliance");
expect(source).toContain("DataTableFilterCustom");
expect(source).not.toContain("gridClassName");
});
});
@@ -35,6 +35,9 @@ export const ComplianceHeader = ({
selectedScan,
}: ComplianceHeaderProps) => {
const frameworkFilters = [];
const prependElement = showProviders ? (
<DataCompliance scans={scans} className="w-full sm:col-span-2" />
) : undefined;
// Add CIS Profile Level filter if framework is CIS
if (framework === "CIS") {
@@ -42,6 +45,7 @@ export const ComplianceHeader = ({
key: "cis_profile_level",
labelCheckboxGroup: "Level",
values: ["Level 1", "Level 2"],
width: "wide" as const,
index: 0, // Show first
showSelectAll: false, // No "Select All" option since Level 2 includes Level 1
defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1)
@@ -55,6 +59,7 @@ export const ComplianceHeader = ({
key: "region__in",
labelCheckboxGroup: "Regions",
values: uniqueRegions,
width: "wide" as const,
index: 1, // Show after framework filters
},
]
@@ -77,9 +82,11 @@ export const ComplianceHeader = ({
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
{/* Showed in the compliance page */}
{showProviders && <DataCompliance scans={scans} />}
{!hideFilters && allFilters.length > 0 && (
<DataTableFilterCustom filters={allFilters} />
{!hideFilters && (allFilters.length > 0 || showProviders) && (
<DataTableFilterCustom
filters={allFilters}
prependElement={prependElement}
/>
)}
</div>
{logoPath && complianceTitle && (
@@ -7,11 +7,13 @@ import {
ScanSelector,
SelectScanComplianceDataProps,
} from "@/components/compliance/compliance-header/index";
import { cn } from "@/lib/utils";
interface DataComplianceProps {
scans: SelectScanComplianceDataProps["scans"];
className?: string;
}
export const DataCompliance = ({ scans }: DataComplianceProps) => {
export const DataCompliance = ({ scans, className }: DataComplianceProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -36,7 +38,7 @@ export const DataCompliance = ({ scans }: DataComplianceProps) => {
};
return (
<div className="flex max-w-fit">
<div className={cn("w-full", className)}>
<ScanSelector
scans={scans}
selectedScanId={selectedScanId}
@@ -1,2 +1,3 @@
export * from "./compliance-filters";
export * from "./data-compliance";
export * from "./scan-selector";
@@ -1,5 +1,6 @@
"use client";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Select,
SelectContent,
@@ -7,6 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { getScanEntityLabel } from "@/lib/helper-filters";
import { ProviderType, ScanProps } from "@/types";
import { ComplianceScanInfo } from "./compliance-scan-info";
@@ -29,6 +31,7 @@ export const ScanSelector = ({
onSelectionChange,
}: SelectScanComplianceDataProps) => {
const selectedScan = scans.find((item) => item.id === selectedScanId);
const triggerLabel = selectedScan ? getScanEntityLabel(selectedScan) : "";
return (
<Select
@@ -39,21 +42,28 @@ export const ScanSelector = ({
}
}}
>
<SelectTrigger className="w-full max-w-[360px]">
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a scan">
{selectedScan ? (
<ComplianceScanInfo scan={selectedScan} />
<>
<span className="text-text-neutral-secondary shrink-0 text-xs">
Scan:
</span>
<Badge variant="tag" className="truncate">
{triggerLabel}
</Badge>
</>
) : (
"Select a scan"
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-w-[360px]">
<SelectContent>
{scans.map((scan) => (
<SelectItem
key={scan.id}
value={scan.id}
className="data-[state=checked]:bg-bg-neutral-tertiary"
className="data-[state=checked]:bg-bg-neutral-tertiary [&_svg:not([class*='size-'])]:size-6"
>
<ComplianceScanInfo scan={scan} />
</SelectItem>
@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import { ComplianceCard } from "@/components/compliance/compliance-card";
import { DataTableSearch } from "@/components/ui/table/data-table-search";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { ScanEntity } from "@/types/scans";
interface ComplianceOverviewGridProps {
frameworks: ComplianceOverviewData[];
scanId: string;
selectedScan?: ScanEntity;
}
export const ComplianceOverviewGrid = ({
frameworks,
scanId,
selectedScan,
}: ComplianceOverviewGridProps) => {
const [searchTerm, setSearchTerm] = useState("");
const filteredFrameworks = frameworks.filter((compliance) =>
compliance.attributes.framework
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
return (
<>
<div className="flex items-center justify-between gap-4">
<DataTableSearch
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search frameworks..."
/>
<span className="text-text-neutral-secondary shrink-0 text-sm">
{filteredFrameworks.length.toLocaleString()} Total Entries
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{filteredFrameworks.map((compliance) => {
const { attributes, id } = compliance;
const {
framework,
version,
requirements_passed,
total_requirements,
} = attributes;
return (
<ComplianceCard
key={id}
title={framework}
version={version}
passingRequirements={requirements_passed}
totalRequirements={total_requirements}
prevPassingRequirements={requirements_passed}
prevTotalRequirements={total_requirements}
scanId={scanId}
complianceId={id}
id={id}
selectedScan={selectedScan}
/>
);
})}
</div>
</>
);
};
+2
View File
@@ -13,10 +13,12 @@ export * from "./compliance-custom-details/cis-details";
export * from "./compliance-custom-details/ens-details";
export * from "./compliance-custom-details/iso-details";
export * from "./compliance-download-container";
export * from "./compliance-header/compliance-filters";
export * from "./compliance-header/compliance-header";
export * from "./compliance-header/compliance-scan-info";
export * from "./compliance-header/data-compliance";
export * from "./compliance-header/scan-selector";
export * from "./compliance-overview-grid";
export * from "./no-scans-available";
export * from "./skeletons/bar-chart-skeleton";
export * from "./skeletons/compliance-accordion-skeleton";
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("ComplianceSkeletonGrid", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "compliance-grid-skeleton.tsx");
const source = readFileSync(filePath, "utf8");
it("uses shadcn skeletons instead of Hero UI", () => {
expect(source).toContain('from "@/components/shadcn/skeleton/skeleton"');
expect(source).not.toContain("@heroui/card");
expect(source).not.toContain("@heroui/skeleton");
});
});
@@ -1,19 +1,11 @@
import { Card } from "@heroui/card";
import { Skeleton } from "@heroui/skeleton";
import React from "react";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const ComplianceSkeletonGrid = () => {
return (
<Card className="h-fit w-full p-4">
<div className="3xl:grid-cols-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
{[...Array(28)].map((_, index) => (
<div key={index} className="flex flex-col gap-4">
<Skeleton className="h-28 rounded-lg">
<div className="bg-default-300 h-full"></div>
</Skeleton>
</div>
))}
</div>
</Card>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{[...Array(28)].map((_, index) => (
<Skeleton key={index} className="h-28 rounded-xl" />
))}
</div>
);
};
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("ThreatScoreBadge", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "threatscore-badge.tsx");
const source = readFileSync(filePath, "utf8");
it("uses shadcn card and progress components instead of Hero UI", () => {
expect(source).toContain('from "@/components/shadcn/card/card"');
expect(source).toContain('from "@/components/shadcn/progress"');
expect(source).not.toContain("@heroui/card");
expect(source).not.toContain("@heroui/progress");
});
it("uses ActionDropdown for downloads instead of ComplianceDownloadContainer", () => {
expect(source).toContain("ActionDropdown");
expect(source).toContain("ActionDropdownItem");
expect(source).toContain("downloadComplianceCsv");
expect(source).toContain("downloadComplianceReportPdf");
expect(source).not.toContain("ComplianceDownloadContainer");
});
it("does not use Collapsible components", () => {
expect(source).not.toContain("Collapsible");
expect(source).not.toContain("CollapsibleTrigger");
expect(source).not.toContain("CollapsibleContent");
});
});
+80 -109
View File
@@ -1,27 +1,24 @@
"use client";
import { Card, CardBody } from "@heroui/card";
import { Progress } from "@heroui/progress";
import {
ChevronDown,
ChevronUp,
DownloadIcon,
FileTextIcon,
} from "lucide-react";
import { DownloadIcon, FileTextIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import type { SectionScores } from "@/actions/overview/threat-score";
import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo";
import { Button } from "@/components/shadcn/button/button";
import { Card, CardContent } from "@/components/shadcn/card/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/shadcn/collapsible";
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Progress } from "@/components/shadcn/progress";
import { toast } from "@/components/ui";
import { COMPLIANCE_REPORT_TYPES } from "@/lib/compliance/compliance-report-types";
import { getScoreColor, getScoreTextClass } from "@/lib/compliance/score-utils";
import {
getScoreColor,
getScoreIndicatorClass,
getScoreTextClass,
} from "@/lib/compliance/score-utils";
import {
downloadComplianceCsv,
downloadComplianceReportPdf,
@@ -44,9 +41,8 @@ export const ThreatScoreBadge = ({
}: ThreatScoreBadgeProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
const complianceId = `prowler_threatscore_${provider.toLowerCase()}`;
@@ -69,7 +65,18 @@ export const ThreatScoreBadge = ({
router.push(`${path}?${params.toString()}`);
};
const handleDownloadCsv = async () => {
if (isDownloadingCsv) return;
setIsDownloadingCsv(true);
try {
await downloadComplianceCsv(scanId, complianceId, toast);
} finally {
setIsDownloadingCsv(false);
}
};
const handleDownloadPdf = async () => {
if (isDownloadingPdf) return;
setIsDownloadingPdf(true);
try {
await downloadComplianceReportPdf(
@@ -82,23 +89,12 @@ export const ThreatScoreBadge = ({
}
};
const handleDownloadCsv = async () => {
setIsDownloadingCsv(true);
try {
await downloadComplianceCsv(scanId, complianceId, toast);
} finally {
setIsDownloadingCsv(false);
}
};
return (
<Card
shadow="sm"
className="border-default-200 h-full border bg-transparent"
>
<CardBody className="flex flex-row flex-wrap items-center justify-between gap-3 p-4 lg:flex-col lg:items-stretch lg:justify-start">
<Card variant="base" padding="md" className="relative gap-4">
<CardContent className="flex flex-col gap-4 p-0 pr-14 lg:flex-row lg:items-start lg:gap-6">
{/* Clickable ThreatScore button */}
<button
className="border-default-200 hover:border-default-300 hover:bg-default-50/50 flex w-full cursor-pointer flex-row items-center justify-between gap-4 rounded-lg border bg-transparent p-3 transition-all"
className="border-border-neutral-secondary bg-bg-neutral-tertiary hover:border-border-neutral-primary hover:bg-bg-neutral-secondary flex shrink-0 cursor-pointer flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors lg:pr-3"
onClick={handleCardClick}
type="button"
>
@@ -111,92 +107,67 @@ export const ThreatScoreBadge = ({
<Progress
aria-label="ThreatScore progress"
value={score}
color={getScoreColor(score)}
size="sm"
className="w-24"
className="border-border-neutral-secondary h-2.5 w-24 border"
indicatorClassName={getScoreIndicatorClass(getScoreColor(score))}
/>
</div>
</button>
{/* Pillar breakdown — always visible */}
{sectionScores && Object.keys(sectionScores).length > 0 && (
<Collapsible
open={isExpanded}
onOpenChange={setIsExpanded}
className="w-full"
>
<CollapsibleTrigger
aria-label={
isExpanded ? "Hide pillar breakdown" : "Show pillar breakdown"
}
className="text-default-500 hover:text-default-700 flex w-auto items-center justify-center gap-1 py-1 text-xs transition-colors lg:w-full"
>
{isExpanded ? (
<>
<ChevronUp size={14} />
Hide pillar breakdown
</>
) : (
<>
<ChevronDown size={14} />
Show pillar breakdown
</>
)}
</CollapsibleTrigger>
<CollapsibleContent className="border-default-200 mt-2 w-full space-y-2 border-t pt-2">
{Object.entries(sectionScores)
.sort(([, a], [, b]) => a - b)
.map(([section, sectionScore]) => (
<div
key={section}
className="flex items-center gap-2 text-xs"
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
{Object.entries(sectionScores)
.sort(([, a], [, b]) => a - b)
.map(([section, sectionScore]) => (
<div key={section} className="flex items-center gap-2 text-xs">
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
{section}
</span>
<Progress
aria-label={`${section} score`}
value={sectionScore}
className="border-border-neutral-secondary h-2 min-w-16 flex-1 border"
indicatorClassName={getScoreIndicatorClass(
getScoreColor(sectionScore),
)}
/>
<span
className={`w-12 shrink-0 text-right font-medium ${getScoreTextClass(sectionScore)}`}
>
<span className="text-default-600 w-1/3 min-w-0 shrink-0 truncate">
{section}
</span>
<Progress
aria-label={`${section} score`}
value={sectionScore}
color={getScoreColor(sectionScore)}
size="sm"
className="min-w-16 flex-1"
/>
<span
className={`w-12 shrink-0 text-right font-medium ${getScoreTextClass(sectionScore)}`}
>
{sectionScore.toFixed(1)}%
</span>
</div>
))}
</CollapsibleContent>
</Collapsible>
{sectionScore.toFixed(1)}%
</span>
</div>
))}
</div>
)}
</CardContent>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={handleDownloadPdf}
disabled={isDownloadingPdf || isDownloadingCsv}
>
<DownloadIcon
size={14}
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
PDF
</Button>
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={handleDownloadCsv}
disabled={isDownloadingCsv || isDownloadingPdf}
>
<FileTextIcon size={14} />
CSV
</Button>
</div>
</CardBody>
{/* ActionDropdown for downloads — top-right */}
<div className="absolute top-3 right-4">
<ActionDropdown
variant="bordered"
ariaLabel="Open compliance export actions"
>
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
/>
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
/>
</ActionDropdown>
</div>
</Card>
);
};
+1
View File
@@ -11,6 +11,7 @@ export * from "./drawer";
export * from "./dropdown/dropdown";
export * from "./info-field";
export * from "./input/input";
export * from "./progress";
export * from "./search-input/search-input";
export * from "./select/multiselect";
export * from "./select/select";
+42
View File
@@ -0,0 +1,42 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { ComponentProps } from "react";
import { cn } from "@/lib/utils";
interface ProgressProps extends ComponentProps<typeof ProgressPrimitive.Root> {
indicatorClassName?: string;
}
function Progress({
className,
value = 0,
indicatorClassName,
...props
}: ProgressProps) {
const normalizedValue = value ?? 0;
return (
<ProgressPrimitive.Root
data-slot="progress"
value={normalizedValue}
className={cn(
"bg-bg-neutral-tertiary relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn(
"bg-button-primary h-full w-full flex-1 transition-all",
indicatorClassName,
)}
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
+6
View File
@@ -53,3 +53,9 @@ export function getScoreLabel(score: number): string {
if (score >= SCORE_THRESHOLDS.WARNING) return "Moderate Risk";
return "Critical Risk";
}
export function getScoreIndicatorClass(variant: ScoreColorVariant): string {
if (variant === "danger") return "bg-bg-fail";
if (variant === "warning") return "bg-bg-warning";
return "bg-bg-pass";
}
+9 -1
View File
@@ -216,6 +216,10 @@ export const downloadComplianceCsv = async (
complianceId: string,
toast: ReturnType<typeof useToast>["toast"],
): Promise<void> => {
toast({
title: "Download Started",
description: "Preparing the CSV report. This may take a moment.",
});
const result = await getComplianceCsv(scanId, complianceId);
await downloadFile(
result,
@@ -236,8 +240,12 @@ export const downloadComplianceReportPdf = async (
reportType: ComplianceReportType,
toast: ReturnType<typeof useToast>["toast"],
): Promise<void> => {
const result = await getCompliancePdfReport(scanId, reportType);
const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType];
toast({
title: "Download Started",
description: `Preparing the ${reportName} PDF report. This may take a moment.`,
});
const result = await getCompliancePdfReport(scanId, reportType);
await downloadFile(
result,
"application/pdf",
+1
View File
@@ -52,6 +52,7 @@
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.7",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.5",
+3
View File
@@ -110,6 +110,9 @@ importers:
'@radix-ui/react-popover':
specifier: 1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-progress':
specifier: 1.1.7
version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-radio-group':
specifier: 1.3.8
version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)