mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat(ui): improve threatscore visualization per pillar (#9773)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Add search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634)
|
||||
- New findings table UI with new design system components, improved filtering UX, and enhanced table interactions [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699)
|
||||
- Add gradient background to Risk Plot for visual risk context [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664)
|
||||
- Add ThreatScore pillar breakdown to Compliance Summary page and detail view [(#9773)](https://github.com/prowler-cloud/prowler/pull/9773)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -16,6 +17,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- 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)
|
||||
- Remove unused hasDots prop from RadialChart component [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664)
|
||||
- Add showCenterLabel prop to RadialChart for optional center text display [(#9773)](https://github.com/prowler-cloud/prowler/pull/9773)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getComplianceOverviewMetadataInfo,
|
||||
getComplianceRequirements,
|
||||
} from "@/actions/compliances";
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
ComplianceDownloadButton,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
// SectionsFailureRateCard,
|
||||
// SectionsFailureRateCardSkeleton,
|
||||
SkeletonAccordion,
|
||||
ThreatScoreBreakdownCard,
|
||||
ThreatScoreBreakdownCardSkeleton,
|
||||
TopFailedSectionsCard,
|
||||
TopFailedSectionsCardSkeleton,
|
||||
} from "@/components/compliance";
|
||||
@@ -22,6 +25,7 @@ import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AttributesData,
|
||||
Framework,
|
||||
@@ -86,14 +90,33 @@ export default async function ComplianceDetail({
|
||||
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
|
||||
// Detect if this is a ThreatScore compliance view
|
||||
const isThreatScore = complianceId?.includes("prowler_threatscore");
|
||||
|
||||
// Fetch ThreatScore data if applicable
|
||||
let threatScoreData = null;
|
||||
if (isThreatScore && selectedScanId) {
|
||||
const threatScoreResponse = await getThreatScore({
|
||||
filters: { "filter[scan_id]": selectedScanId },
|
||||
});
|
||||
|
||||
if (threatScoreResponse?.data && threatScoreResponse.data.length > 0) {
|
||||
const snapshot = threatScoreResponse.data[0];
|
||||
threatScoreData = {
|
||||
overallScore: parseFloat(snapshot.attributes.overall_score),
|
||||
sectionScores: snapshot.attributes.section_scores,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use compliance_name from attributes if available, otherwise fallback to formatted title
|
||||
const complianceName = attributesData?.data?.[0]?.attributes?.compliance_name;
|
||||
const finalPageTitle = complianceName ? `${complianceName}` : pageTitle;
|
||||
|
||||
return (
|
||||
<ContentLayout title={finalPageTitle}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ComplianceHeader
|
||||
scans={[]}
|
||||
uniqueRegions={uniqueRegions}
|
||||
@@ -110,10 +133,11 @@ export default async function ComplianceDetail({
|
||||
const reportType = getReportTypeForFramework(framework);
|
||||
|
||||
return selectedScanId && reportType ? (
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<div className="mb-4 flex-shrink-0 self-end sm:mb-0 sm:self-start sm:pt-1">
|
||||
<ComplianceDownloadButton
|
||||
scanId={selectedScanId}
|
||||
reportType={reportType}
|
||||
label="Download report"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
@@ -124,7 +148,18 @@ export default async function ComplianceDetail({
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
|
||||
{/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-6 md:grid-cols-2",
|
||||
isThreatScore && "xl:grid-cols-[minmax(280px,320px)_auto_1fr]",
|
||||
)}
|
||||
>
|
||||
{isThreatScore && (
|
||||
<div className="md:col-span-2 xl:col-span-1">
|
||||
<ThreatScoreBreakdownCardSkeleton />
|
||||
</div>
|
||||
)}
|
||||
<RequirementsStatusCardSkeleton />
|
||||
<TopFailedSectionsCardSkeleton />
|
||||
{/* <SectionsFailureRateCardSkeleton /> */}
|
||||
@@ -139,6 +174,7 @@ export default async function ComplianceDetail({
|
||||
region={regionFilter}
|
||||
filter={cisProfileFilter}
|
||||
attributesData={attributesData}
|
||||
threatScoreData={threatScoreData}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
@@ -151,12 +187,17 @@ const SSRComplianceContent = async ({
|
||||
region,
|
||||
filter,
|
||||
attributesData,
|
||||
threatScoreData,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
region?: string;
|
||||
filter?: string;
|
||||
attributesData: AttributesData;
|
||||
threatScoreData: {
|
||||
overallScore: number;
|
||||
sectionScores: Record<string, number>;
|
||||
} | null;
|
||||
}) => {
|
||||
const requirementsData = await getComplianceRequirements({
|
||||
complianceId,
|
||||
@@ -168,7 +209,7 @@ const SSRComplianceContent = async ({
|
||||
if (!scanId || type === "tasks") {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<RequirementsStatusCard pass={0} fail={0} manual={0} />
|
||||
<TopFailedSectionsCard sections={[]} />
|
||||
{/* <SectionsFailureRateCard categories={[]} /> */}
|
||||
@@ -199,7 +240,22 @@ const SSRComplianceContent = async ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-stretch">
|
||||
{/* Charts section */}
|
||||
{/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-6 md:grid-cols-2",
|
||||
threatScoreData && "xl:grid-cols-[minmax(280px,320px)_auto_1fr]",
|
||||
)}
|
||||
>
|
||||
{threatScoreData && (
|
||||
<div className="md:col-span-2 xl:col-span-1">
|
||||
<ThreatScoreBreakdownCard
|
||||
overallScore={threatScoreData.overallScore}
|
||||
sectionScores={threatScoreData.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<RequirementsStatusCard
|
||||
pass={totalRequirements.pass}
|
||||
fail={totalRequirements.fail}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
getComplianceAttributes,
|
||||
getComplianceOverviewMetadataInfo,
|
||||
getComplianceRequirements,
|
||||
getCompliancesOverview,
|
||||
} from "@/actions/compliances";
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import {
|
||||
ComplianceCard,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from "@/components/compliance";
|
||||
import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { calculateThreatScore } from "@/lib/compliance/threatscore-calculator";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
ScanEntity,
|
||||
@@ -113,51 +111,46 @@ export default async function Compliance({
|
||||
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
|
||||
// Fetch ThreatScore data if we have a selected scan
|
||||
// Fetch ThreatScore data from API if we have a selected scan
|
||||
let threatScoreData = null;
|
||||
if (
|
||||
selectedScanId &&
|
||||
typeof selectedScanId === "string" &&
|
||||
selectedScan?.providerInfo?.provider
|
||||
) {
|
||||
const complianceId = `prowler_threatscore_${selectedScan.providerInfo.provider.toLowerCase()}`;
|
||||
if (selectedScanId && typeof selectedScanId === "string") {
|
||||
const threatScoreResponse = await getThreatScore({
|
||||
filters: { "filter[scan_id]": selectedScanId },
|
||||
});
|
||||
|
||||
const [attributesData, requirementsData] = await Promise.all([
|
||||
getComplianceAttributes(complianceId),
|
||||
getComplianceRequirements({
|
||||
complianceId,
|
||||
scanId: selectedScanId,
|
||||
}),
|
||||
]);
|
||||
|
||||
threatScoreData = calculateThreatScore(attributesData, requirementsData);
|
||||
if (threatScoreResponse?.data && threatScoreResponse.data.length > 0) {
|
||||
const snapshot = threatScoreResponse.data[0];
|
||||
threatScoreData = {
|
||||
score: parseFloat(snapshot.attributes.overall_score),
|
||||
sectionScores: snapshot.attributes.section_scores,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Compliance" icon="lucide:shield-check">
|
||||
{selectedScanId ? (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col gap-6">
|
||||
<div className="flex flex-col items-start justify-between lg:flex-row lg:gap-6">
|
||||
<div className="flex-1">
|
||||
<ComplianceHeader
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
/>
|
||||
</div>
|
||||
{threatScoreData &&
|
||||
typeof selectedScanId === "string" &&
|
||||
selectedScan && (
|
||||
<div className="w-[360px] flex-shrink-0">
|
||||
<ThreatScoreBadge
|
||||
score={threatScoreData.score}
|
||||
scanId={selectedScanId}
|
||||
provider={selectedScan.providerInfo.provider}
|
||||
selectedScan={selectedScanData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<Suspense key={searchParamsKey} fallback={<ComplianceSkeletonGrid />}>
|
||||
<SSRComplianceGrid
|
||||
|
||||
@@ -4,7 +4,7 @@ export function RequirementsStatusCardSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[328px] flex-col justify-between md:max-w-[312px]"
|
||||
className="flex h-full min-h-[372px] flex-col justify-between xl:max-w-[400px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-7 w-[260px] rounded-xl" />
|
||||
@@ -24,10 +24,7 @@ export function RequirementsStatusCardSkeleton() {
|
||||
|
||||
export function TopFailedSectionsCardSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[328px] flex-1 flex-col"
|
||||
>
|
||||
<Card variant="base" className="flex h-full min-h-[372px] w-full flex-col">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function RequirementsStatusCard({
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] flex-col justify-between"
|
||||
className="flex h-full min-h-[372px] flex-col justify-between xl:max-w-[400px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Requirements Status</CardTitle>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { Progress } from "@heroui/progress";
|
||||
|
||||
import type { SectionScores } from "@/actions/overview/threat-score";
|
||||
import { RadialChart } from "@/components/graphs/radial-chart";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import {
|
||||
getScoreColor,
|
||||
getScoreLabel,
|
||||
getScoreLevel,
|
||||
getScoreTextClass,
|
||||
SCORE_COLORS,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
|
||||
export interface ThreatScoreBreakdownCardProps {
|
||||
overallScore: number;
|
||||
sectionScores: SectionScores;
|
||||
}
|
||||
|
||||
export function ThreatScoreBreakdownCard({
|
||||
overallScore,
|
||||
sectionScores,
|
||||
}: ThreatScoreBreakdownCardProps) {
|
||||
const scoreLevel = getScoreLevel(overallScore);
|
||||
const scoreColor = SCORE_COLORS[scoreLevel];
|
||||
|
||||
// Convert section scores to tooltip data for the radial chart
|
||||
const tooltipData = Object.entries(sectionScores).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
color: SCORE_COLORS[getScoreLevel(value)],
|
||||
}));
|
||||
|
||||
// Sort sections by score (lowest first to highlight areas needing attention)
|
||||
const sortedSections = Object.entries(sectionScores).sort(
|
||||
([, a], [, b]) => a - b,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card variant="base" className="flex h-full w-full flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>ThreatScore Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
{/* Mobile: vertical, Tablet: horizontal, Desktop: vertical */}
|
||||
<CardContent className="flex flex-1 flex-col gap-4 md:flex-row md:items-stretch lg:flex-col">
|
||||
{/* Overall Score - Large radial chart matching overview style */}
|
||||
<div className="flex w-full flex-col items-center justify-center md:w-[160px] md:flex-shrink-0 lg:w-full">
|
||||
<div className="relative mx-auto h-[140px] w-[160px]">
|
||||
<div className="absolute top-0 left-1/2 z-1 w-full -translate-x-1/2">
|
||||
<RadialChart
|
||||
percentage={overallScore}
|
||||
label="Score"
|
||||
color={scoreColor}
|
||||
backgroundColor={SCORE_COLORS.NEUTRAL}
|
||||
height={170}
|
||||
innerRadius={70}
|
||||
outerRadius={90}
|
||||
startAngle={200}
|
||||
endAngle={-20}
|
||||
tooltipData={tooltipData}
|
||||
/>
|
||||
</div>
|
||||
{/* Overlaid label below percentage */}
|
||||
<div className="text-text-neutral-secondary pointer-events-none absolute top-[65%] left-1/2 z-0 -translate-x-1/2 -translate-y-1/2 text-center text-sm text-nowrap">
|
||||
{getScoreLabel(overallScore)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pillar Breakdown */}
|
||||
<Card variant="inner" padding="sm" className="min-w-0 flex-1">
|
||||
<div className="mb-2">
|
||||
<span className="text-default-600 text-xs font-medium tracking-wide uppercase">
|
||||
Score by Pillar
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedSections.map(([section, score]) => (
|
||||
<div key={section} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-default-700 truncate pr-2">
|
||||
{section}
|
||||
</span>
|
||||
<span className={`font-semibold ${getScoreTextClass(score)}`}>
|
||||
{score.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`${section} score`}
|
||||
value={score}
|
||||
color={getScoreColor(score)}
|
||||
size="md"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_PILLAR_COUNT = 5;
|
||||
|
||||
export function ThreatScoreBreakdownCardSkeleton() {
|
||||
return (
|
||||
<Card variant="base" className="flex h-full w-full animate-pulse flex-col">
|
||||
<CardHeader>
|
||||
<div className="bg-default-200 h-5 w-40 rounded" />
|
||||
</CardHeader>
|
||||
{/* Mobile: vertical, Tablet: horizontal, Desktop: vertical */}
|
||||
<CardContent className="flex flex-1 flex-col gap-4 md:flex-row md:items-stretch lg:flex-col">
|
||||
<div className="flex w-full flex-col items-center justify-center md:w-[160px] md:flex-shrink-0 lg:w-full">
|
||||
<div className="bg-default-200 mx-auto h-[140px] w-[140px] rounded-full" />
|
||||
</div>
|
||||
<Card variant="inner" padding="sm" className="min-w-0 flex-1">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: SKELETON_PILLAR_COUNT }, (_, i) => (
|
||||
<div key={i} className="space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
<div className="bg-default-200 h-3 w-28 rounded" />
|
||||
<div className="bg-default-200 h-3 w-10 rounded" />
|
||||
</div>
|
||||
<div className="bg-default-200 h-2 w-full rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -34,10 +34,7 @@ export function TopFailedSectionsCard({
|
||||
: "Top Failed Sections";
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] w-full flex-col sm:min-w-[500px]"
|
||||
>
|
||||
<Card variant="base" className="flex h-full min-h-[372px] w-full flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -4,6 +4,11 @@ import { DownloadIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { toast } from "@/components/ui";
|
||||
import {
|
||||
COMPLIANCE_REPORT_BUTTON_LABELS,
|
||||
@@ -15,12 +20,15 @@ interface ComplianceDownloadButtonProps {
|
||||
scanId: string;
|
||||
reportType: ComplianceReportType;
|
||||
label?: string;
|
||||
/** Show only icon with tooltip on mobile (sm and below) */
|
||||
iconOnlyOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const ComplianceDownloadButton = ({
|
||||
scanId,
|
||||
reportType,
|
||||
label,
|
||||
iconOnlyOnMobile = false,
|
||||
}: ComplianceDownloadButtonProps) => {
|
||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||
|
||||
@@ -34,6 +42,47 @@ export const ComplianceDownloadButton = ({
|
||||
};
|
||||
|
||||
const defaultLabel = COMPLIANCE_REPORT_BUTTON_LABELS[reportType];
|
||||
const buttonLabel = label || defaultLabel;
|
||||
|
||||
if (iconOnlyOnMobile) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile and Tablet: Icon only with tooltip */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="md:hidden"
|
||||
aria-label={buttonLabel}
|
||||
>
|
||||
<DownloadIcon
|
||||
className={isDownloading ? "animate-download-icon" : ""}
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{buttonLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Desktop: Full button with label */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<DownloadIcon
|
||||
className={isDownloading ? "animate-download-icon" : ""}
|
||||
size={16}
|
||||
/>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -46,7 +95,7 @@ export const ComplianceDownloadButton = ({
|
||||
className={isDownloading ? "animate-download-icon" : ""}
|
||||
size={16}
|
||||
/>
|
||||
{label || defaultLabel}
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import Image from "next/image";
|
||||
|
||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||
@@ -72,7 +71,7 @@ export const ComplianceHeader = ({
|
||||
return (
|
||||
<>
|
||||
{hasContent && (
|
||||
<div className="flex w-full items-start justify-between gap-6">
|
||||
<div className="flex w-full items-start justify-between gap-6 sm:mb-8">
|
||||
<div className="flex flex-1 flex-col justify-end gap-4">
|
||||
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
|
||||
|
||||
@@ -95,7 +94,6 @@ export const ComplianceHeader = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasContent && <Spacer y={8} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./compliance-charts/chart-skeletons";
|
||||
export * from "./compliance-charts/heatmap-chart";
|
||||
export * from "./compliance-charts/requirements-status-card";
|
||||
export * from "./compliance-charts/sections-failure-rate-card";
|
||||
export * from "./compliance-charts/threatscore-breakdown-card";
|
||||
export * from "./compliance-charts/top-failed-sections-card";
|
||||
export * from "./compliance-custom-details/cis-details";
|
||||
export * from "./compliance-custom-details/ens-details";
|
||||
|
||||
@@ -2,14 +2,26 @@
|
||||
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import { DownloadIcon, FileTextIcon } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
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 {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/shadcn/collapsible";
|
||||
import { toast } from "@/components/ui";
|
||||
import { COMPLIANCE_REPORT_TYPES } from "@/lib/compliance/compliance-report-types";
|
||||
import { getScoreColor, getScoreTextClass } from "@/lib/compliance/score-utils";
|
||||
import {
|
||||
downloadComplianceCsv,
|
||||
downloadComplianceReportPdf,
|
||||
@@ -21,6 +33,7 @@ interface ThreatScoreBadgeProps {
|
||||
scanId: string;
|
||||
provider: string;
|
||||
selectedScan?: ScanEntity;
|
||||
sectionScores?: SectionScores;
|
||||
}
|
||||
|
||||
export const ThreatScoreBadge = ({
|
||||
@@ -28,26 +41,16 @@ export const ThreatScoreBadge = ({
|
||||
scanId,
|
||||
provider,
|
||||
selectedScan,
|
||||
sectionScores,
|
||||
}: ThreatScoreBadgeProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
|
||||
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const complianceId = `prowler_threatscore_${provider.toLowerCase()}`;
|
||||
|
||||
const getScoreColor = (): "success" | "warning" | "danger" => {
|
||||
if (score >= 80) return "success";
|
||||
if (score >= 40) return "warning";
|
||||
return "danger";
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (score >= 80) return "text-success";
|
||||
if (score >= 40) return "text-warning";
|
||||
return "text-text-error";
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
const title = "ProwlerThreatScore";
|
||||
const version = "1.0";
|
||||
@@ -105,27 +108,81 @@ export const ThreatScoreBadge = ({
|
||||
shadow="sm"
|
||||
className="border-default-200 h-full border bg-transparent"
|
||||
>
|
||||
<CardBody className="flex flex-col gap-3 p-4">
|
||||
<CardBody className="flex flex-row flex-wrap items-center justify-between gap-3 p-4 lg:flex-col lg:items-stretch lg:justify-start">
|
||||
<button
|
||||
className="border-default-200 hover:border-default-300 hover:bg-default-50/50 flex cursor-pointer flex-row items-center gap-4 rounded-lg border bg-transparent p-3 transition-all"
|
||||
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"
|
||||
onClick={handleCardClick}
|
||||
type="button"
|
||||
>
|
||||
<ThreatScoreLogo />
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`text-2xl font-bold ${getTextColor()}`}>
|
||||
<span className={`text-2xl font-bold ${getScoreTextClass(score)}`}>
|
||||
{score}%
|
||||
</span>
|
||||
<Progress
|
||||
aria-label="ThreatScore progress"
|
||||
value={score}
|
||||
color={getScoreColor()}
|
||||
color={getScoreColor(score)}
|
||||
size="sm"
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -25,6 +25,7 @@ interface RadialChartProps {
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
tooltipData?: TooltipItem[];
|
||||
showCenterLabel?: boolean;
|
||||
}
|
||||
|
||||
interface RadialChartTooltipProps {
|
||||
@@ -48,7 +49,7 @@ const CustomTooltip = ({ active, payload }: RadialChartTooltipProps) => {
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary rounded-xl border px-3 py-1.5 shadow-lg">
|
||||
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary min-w-[238px] rounded-xl border px-3 py-1.5 shadow-lg">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{tooltipItems.map((item: TooltipItem, index: number) => (
|
||||
<div key={index} className="flex items-end gap-1">
|
||||
@@ -81,6 +82,7 @@ export function RadialChart({
|
||||
startAngle = 90,
|
||||
endAngle = -270,
|
||||
tooltipData,
|
||||
showCenterLabel = true,
|
||||
}: RadialChartProps) {
|
||||
// Calculate the real barSize based on the difference
|
||||
const barSize = outerRadius - innerRadius;
|
||||
@@ -126,18 +128,20 @@ export function RadialChart({
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
<text
|
||||
x="50%"
|
||||
y="38%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-2xl font-bold"
|
||||
style={{
|
||||
fill: "var(--text-neutral-secondary)",
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</text>
|
||||
{showCenterLabel && (
|
||||
<text
|
||||
x="50%"
|
||||
y="38%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-2xl font-bold"
|
||||
style={{
|
||||
fill: "var(--text-neutral-secondary)",
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</text>
|
||||
)}
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
55
ui/lib/compliance/score-utils.ts
Normal file
55
ui/lib/compliance/score-utils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Score utility functions for ThreatScore visualization.
|
||||
* Used by threatscore-breakdown-card and threatscore-badge components.
|
||||
*/
|
||||
|
||||
export const SCORE_THRESHOLDS = {
|
||||
SUCCESS: 80,
|
||||
WARNING: 40,
|
||||
} as const;
|
||||
|
||||
export const SCORE_COLORS = {
|
||||
DANGER: "var(--bg-fail-primary)",
|
||||
WARNING: "var(--bg-warning-primary)",
|
||||
SUCCESS: "var(--bg-pass-primary)",
|
||||
NEUTRAL: "var(--bg-neutral-tertiary)",
|
||||
} as const;
|
||||
|
||||
export const SCORE_LEVELS = {
|
||||
SUCCESS: "SUCCESS",
|
||||
WARNING: "WARNING",
|
||||
DANGER: "DANGER",
|
||||
} as const;
|
||||
export type ScoreLevel = (typeof SCORE_LEVELS)[keyof typeof SCORE_LEVELS];
|
||||
|
||||
export const SCORE_COLOR_VARIANTS = {
|
||||
SUCCESS: "success",
|
||||
WARNING: "warning",
|
||||
DANGER: "danger",
|
||||
} as const;
|
||||
export type ScoreColorVariant =
|
||||
(typeof SCORE_COLOR_VARIANTS)[keyof typeof SCORE_COLOR_VARIANTS];
|
||||
|
||||
export function getScoreLevel(score: number): ScoreLevel {
|
||||
if (score >= SCORE_THRESHOLDS.SUCCESS) return "SUCCESS";
|
||||
if (score >= SCORE_THRESHOLDS.WARNING) return "WARNING";
|
||||
return "DANGER";
|
||||
}
|
||||
|
||||
export function getScoreColor(score: number): ScoreColorVariant {
|
||||
if (score >= SCORE_THRESHOLDS.SUCCESS) return "success";
|
||||
if (score >= SCORE_THRESHOLDS.WARNING) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export function getScoreTextClass(score: number): string {
|
||||
if (score >= SCORE_THRESHOLDS.SUCCESS) return "text-success";
|
||||
if (score >= SCORE_THRESHOLDS.WARNING) return "text-warning";
|
||||
return "text-danger";
|
||||
}
|
||||
|
||||
export function getScoreLabel(score: number): string {
|
||||
if (score >= SCORE_THRESHOLDS.SUCCESS) return "Secure";
|
||||
if (score >= SCORE_THRESHOLDS.WARNING) return "Moderate Risk";
|
||||
return "Critical Risk";
|
||||
}
|
||||
Reference in New Issue
Block a user