feat(ui): improve threatscore visualization per pillar (#9773)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-01-14 09:05:54 +01:00
committed by GitHub
parent 864b2099c3
commit 211b1b67f9
13 changed files with 432 additions and 89 deletions

View File

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

View File

@@ -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}

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
};

View File

@@ -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} />}
</>
);
};

View File

@@ -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";

View File

@@ -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"

View File

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

View 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";
}