diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 4a5ac26495..8a75c50312 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed +- Integrate Compliance Watchlist with new `/overviews/compliance-watchlist` endpoint [(#9786)](https://github.com/prowler-cloud/prowler/pull/9786) - Refactor ScatterPlot as reusable generic component with TypeScript generics [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Swap Risk Plot axes: X = Fail Findings, Y = Prowler ThreatScore [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Remove duplicate scan_id filter badge from Findings page [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts new file mode 100644 index 0000000000..bc8005edf4 --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts @@ -0,0 +1,72 @@ +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { formatLabel } from "@/lib/categories"; + +import { ComplianceWatchlistResponse } from "./compliance-watchlist.types"; + +export interface EnrichedComplianceWatchlistItem { + id: string; + complianceId: string; + label: string; + icon: ReturnType; + score: number; + requirementsPassed: number; + requirementsFailed: number; + requirementsManual: number; + totalRequirements: number; +} + +/** + * Formats compliance_id into a human-readable label + * e.g., "aws_account_security_onboarding_aws" → "AWS Account Security Onboarding" + * + * Uses the shared formatLabel utility from lib/categories.ts which handles: + * - Acronyms (≤3 chars like AWS, CIS, ISO, PCI, SOC, etc.) + * - Special cases (4+ char acronyms like GDPR, HIPAA, NIST, etc.) + * - Version patterns (e.g., "v1", "v2") + */ +function formatComplianceLabel(complianceId: string): string { + // Remove trailing provider suffix (e.g., "_aws", "_gcp", "_azure") + const withoutProvider = complianceId + .replace(/_aws$/i, "") + .replace(/_gcp$/i, "") + .replace(/_azure$/i, "") + .replace(/_kubernetes$/i, ""); + + return formatLabel(withoutProvider, "_"); +} + +export function adaptComplianceWatchlistResponse( + response: ComplianceWatchlistResponse | undefined, +): EnrichedComplianceWatchlistItem[] { + if (!response?.data) { + return []; + } + + return response.data.map((item) => { + const { + compliance_id, + requirements_passed, + requirements_failed, + requirements_manual, + total_requirements, + } = item.attributes; + + // Defensive conversion: API types are number but JSON parsing edge cases may return strings + const totalReqs = Number(total_requirements) || 0; + const passedReqs = Number(requirements_passed) || 0; + const score = + totalReqs > 0 ? Math.round((passedReqs / totalReqs) * 100) : 0; + + return { + id: item.id, + complianceId: compliance_id, + label: formatComplianceLabel(compliance_id), + icon: getComplianceIcon(compliance_id), + score, + requirementsPassed: requirements_passed, + requirementsFailed: requirements_failed, + requirementsManual: requirements_manual, + totalRequirements: total_requirements, + }; + }); +} diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts new file mode 100644 index 0000000000..647ee99e45 --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts @@ -0,0 +1,31 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +import { ComplianceWatchlistResponse } from "./compliance-watchlist.types"; + +export const getComplianceWatchlist = async ({ + filters = {}, +}: { + filters?: Record; +} = {}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/overviews/compliance-watchlist`); + + // Append filter parameters (provider_id, provider_type, etc.) + // Exclude filter[search] as this endpoint doesn't support text search + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]" && value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching compliance watchlist:", error); + return undefined; + } +}; diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.types.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.types.ts new file mode 100644 index 0000000000..de3c01f05a --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.types.ts @@ -0,0 +1,24 @@ +const COMPLIANCE_WATCHLIST_OVERVIEW_TYPE = { + WATCHLIST_OVERVIEW: "compliance-watchlist-overviews", +} as const; + +type ComplianceWatchlistOverviewType = + (typeof COMPLIANCE_WATCHLIST_OVERVIEW_TYPE)[keyof typeof COMPLIANCE_WATCHLIST_OVERVIEW_TYPE]; + +export interface ComplianceWatchlistOverviewAttributes { + compliance_id: string; + requirements_passed: number; + requirements_failed: number; + requirements_manual: number; + total_requirements: number; +} + +export interface ComplianceWatchlistOverview { + type: ComplianceWatchlistOverviewType; + id: string; + attributes: ComplianceWatchlistOverviewAttributes; +} + +export interface ComplianceWatchlistResponse { + data: ComplianceWatchlistOverview[]; +} diff --git a/ui/actions/overview/compliance-watchlist/index.ts b/ui/actions/overview/compliance-watchlist/index.ts new file mode 100644 index 0000000000..f7943ced53 --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/index.ts @@ -0,0 +1,9 @@ +export { getComplianceWatchlist } from "./compliance-watchlist"; +export { + adaptComplianceWatchlistResponse, + type EnrichedComplianceWatchlistItem, +} from "./compliance-watchlist.adapter"; +export type { + ComplianceWatchlistOverview, + ComplianceWatchlistResponse, +} from "./compliance-watchlist.types"; diff --git a/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx b/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx index 49e3109e9c..e59ab4e915 100644 --- a/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx +++ b/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx @@ -14,11 +14,16 @@ export interface ComplianceData { score: number; } +// Display 7 items to match the card's min-height (405px) without scrolling +const ITEMS_TO_DISPLAY = 7; + export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => { const [isAsc, setIsAsc] = useState(true); + // Sort all items and take top 7 based on current sort order const sortedItems = [...items] .sort((a, b) => (isAsc ? a.score - b.score : b.score - a.score)) + .slice(0, ITEMS_TO_DISPLAY) .map((item) => ({ key: item.id, icon: item.icon ? ( @@ -41,7 +46,7 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => { { descendingLabel="Sort by lowest score" /> } + // TODO: Enable full emptyState with description once API endpoint is implemented + // Full emptyState: { message: "...", description: "to add compliance frameworks to your watchlist.", linkText: "Compliance Dashboard" } emptyState={{ - message: "This space is looking empty.", - description: "to add compliance frameworks to your watchlist.", - linkText: "Compliance Dashboard", + message: "No compliance data available.", }} /> ); diff --git a/ui/app/(prowler)/_overview/watchlist/_components/watchlist-card.tsx b/ui/app/(prowler)/_overview/watchlist/_components/watchlist-card.tsx index a74f4b493e..3d6aeebd8a 100644 --- a/ui/app/(prowler)/_overview/watchlist/_components/watchlist-card.tsx +++ b/ui/app/(prowler)/_overview/watchlist/_components/watchlist-card.tsx @@ -85,12 +85,15 @@ export const WatchlistCard = ({ const isEmpty = items.length === 0; return ( - +
{title} {headerAction}
- + {isEmpty ? (
{/* Icon and message */} @@ -102,19 +105,15 @@ export const WatchlistCard = ({
{/* Description with link */} -

- {emptyState?.description && ctaHref && ( - <> - Visit the{" "} - {" "} - {emptyState.description} - - )} -

+ {emptyState?.description && ctaHref && ( +

+ Visit the{" "} + {" "} + {emptyState.description} +

+ )} ) : ( <> @@ -149,7 +148,7 @@ export const WatchlistCard = ({ } }} className={cn( - "flex h-[54px] items-center justify-between gap-2 px-3 py-[11px]", + "flex h-[54px] min-w-0 items-center justify-between gap-2 px-3 py-[11px]", !isLast && "border-border-neutral-tertiary border-b", isClickable && "hover:bg-bg-neutral-tertiary cursor-pointer", @@ -161,10 +160,10 @@ export const WatchlistCard = ({ )} -

+

{item.label}

-
+

{ const filters = pickFilterParams(searchParams); + const response = await getComplianceWatchlist({ filters }); + const enrichedData = adaptComplianceWatchlistResponse(response); - const response = await getCompliancesOverview({ filters }); - const { data } = adaptComplianceOverviewsResponse(response); - - // Filter out ProwlerThreatScore and limit to 5 items - const items = data - .filter((item) => item.framework !== "ProwlerThreatScore") - .slice(0, 5) - .map((compliance) => ({ - id: compliance.id, - framework: compliance.framework, - label: compliance.label, - icon: compliance.icon, - score: compliance.score, + // Filter out ProwlerThreatScore and pass all items to client + // Client handles sorting and limiting to display count + const items = enrichedData + .filter((item) => !item.complianceId.toLowerCase().includes("threatscore")) + .map((item) => ({ + id: item.id, + framework: item.complianceId, + label: item.label, + icon: item.icon, + score: item.score, })); return ; diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 245fae9ef6..ff9d761d35 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -24,6 +24,7 @@ import { import { StatusChartSkeleton } from "./_overview/status-chart"; import { ThreatScoreSkeleton, ThreatScoreSSR } from "./_overview/threat-score"; import { + ComplianceWatchlistSSR, ServiceWatchlistSSR, WatchlistCardSkeleton, } from "./_overview/watchlist"; @@ -57,19 +58,30 @@ export default async function Home({

-
- }> - - -
-
- }> - - - }> - - + {/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */} +
+
+ }> + + +
+
+ }> + + +
+
+ + {/* Charts column: Attack Surface on top, Findings Over Time below */} +
+ }> + + + }> + + +
diff --git a/ui/lib/categories.ts b/ui/lib/categories.ts index 99d0bb4c8f..8063b3b0ee 100644 --- a/ui/lib/categories.ts +++ b/ui/lib/categories.ts @@ -3,8 +3,15 @@ * Add entries here for edge cases that heuristics can't handle. */ const SPECIAL_CASES: Record = { - // Add special cases here if needed, e.g.: - // "someweirdcase": "SomeWeirdCase", + // Compliance framework acronyms (4+ chars, not caught by length heuristic) + gdpr: "GDPR", + hipaa: "HIPAA", + nist: "NIST", + mitre: "MITRE", + fedramp: "FedRAMP", + ffiec: "FFIEC", + kisa: "KISA", + cisa: "CISA", }; /** @@ -22,14 +29,29 @@ const SPECIAL_CASES: Record = { * - "ec2-imdsv1" -> "EC2 IMDSv1" * - "forensics-ready" -> "Forensics Ready" */ -export function getCategoryLabel(id: string): string { +/** + * Generic label formatter that works with any delimiter. + * Use this for formatting IDs into human-readable labels. + * + * @param id - The ID to format + * @param delimiter - The delimiter to split on (default: "-") + */ +export function formatLabel(id: string, delimiter = "-"): string { return id - .split("-") + .split(delimiter) .map((word) => formatWord(word)) .join(" "); } -function formatWord(word: string): string { +/** + * Converts a category ID to a human-readable label. + * Convenience wrapper for formatLabel with "-" delimiter. + */ +export function getCategoryLabel(id: string): string { + return formatLabel(id, "-"); +} + +export function formatWord(word: string): string { const lowerWord = word.toLowerCase(); // 1. Check special cases dictionary