import { TriangleAlert } from "lucide-react"; import Link from "next/link"; import { Button, Card, CardContent, CardHeader, CardTitle, } from "@/components/shadcn"; import { issuesHref } from "@/lib/issues/scope"; import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts"; import { cn } from "@/lib/utils"; import { Verdict } from "@/types/issues"; interface ExploitabilityChartProps { counts: Record; issues: number; findings: number; actionable: number; /** Findings folded into not-exploitable issues: a control already covers them. */ coveredFindings: number; /** Overview scope, carried into every drill-down so the counts still match. */ scope: Record; } /** The verdicts below the headline, in the order the card lists them. */ const REST: Verdict[] = ["conditional", "unknown", "not_exploitable"]; /** The dot each verdict row wears. */ const TONE_DOT: Record = { [VERDICT_TONE.fail]: "bg-bg-fail", [VERDICT_TONE.warning]: "bg-bg-data-medium", [VERDICT_TONE.pass]: "bg-bg-pass", [VERDICT_TONE.neutral]: "bg-bg-data-muted", }; /** The share bar runs worst to best; likely is a lighter exploitable. */ const BAR_SEGMENT: Record = { exploitable: "bg-bg-fail", likely: "bg-bg-fail/60", conditional: TONE_DOT[VERDICT_TONE.warning], unknown: TONE_DOT[VERDICT_TONE.neutral], not_exploitable: TONE_DOT[VERDICT_TONE.pass], }; /** * The fold, sized for the ThreatScore row. * * The headline is exploitable + likely — the issues someone should look at * tonight — on a red surface. Everything else is work, not an incident, so it * sits as a quiet list underneath. Every number links into the issue list, so * nothing here is a dead end. */ export const ExploitabilityChart = ({ counts, issues, findings, actionable, coveredFindings, scope, }: ExploitabilityChartProps) => { const barTotal = Object.values(counts).reduce((sum, count) => sum + count, 0); return ( Exploitability {issues.toLocaleString()} {issues === 1 ? "issue" : "issues"} Exploitable or likely {counts.exploitable} exploitable · {counts.likely} likely {actionable.toLocaleString()} {/* The ladder's shape: whether this estate is mostly open or mostly blocked, sized by share and never by severity. The rows around it carry the same links for keyboard and screen-reader users. */} {barTotal > 0 && ( )}
    {REST.map((id) => { const verdict = VERDICTS.find((item) => item.id === id); return (
  • {verdict?.label ?? id} {counts[id].toLocaleString()}
  • ); })}
{/* The fold. Without the findings count the issue count says nothing about how much noise was removed, so the two are always shown together. */}

{findings.toLocaleString()} failed findings folded into{" "} {issues.toLocaleString()} {issues === 1 ? "issue" : "issues"} {coveredFindings > 0 && ( <> {" · "} {" "} already covered by a control )}

); };