Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to the **Prowler UI** are documented in this file.

## [1.25.0] (Prowler UNRELEASED)

### 🔄 Changed

- 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)

---

## [1.24.1] (Prowler v5.24.1)

### 🔒 Security
Expand Down
11 changes: 4 additions & 7 deletions ui/actions/compliances/compliances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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,
Expand All @@ -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]) => {
Expand Down
2 changes: 1 addition & 1 deletion ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
16 changes: 16 additions & 0 deletions ui/app/(prowler)/compliance/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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]");
});
});
158 changes: 76 additions & 82 deletions ui/app/(prowler)/compliance/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Info } from "lucide-react";
import { Suspense } from "react";

import {
Expand All @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -81,7 +78,6 @@ export default async function Compliance({
// 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) || "";

// Find the selected scan
const selectedScan = expandedScansData.find(
Expand All @@ -102,7 +98,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,
},
Expand Down Expand Up @@ -131,28 +126,38 @@ 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}
/>
</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}
Expand All @@ -176,25 +181,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 (
Expand All @@ -204,58 +207,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;
<ComplianceOverviewPanel>
<ComplianceOverviewGrid
frameworks={frameworks}
scanId={scanId}
selectedScan={selectedScan}
/>
</ComplianceOverviewPanel>
);
};

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>
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>
);
};
30 changes: 30 additions & 0 deletions ui/components/compliance/compliance-card.test.tsx
Original file line number Diff line number Diff line change
@@ -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"');
});
});
Loading
Loading