Files
prowler/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx
T

119 lines
4.8 KiB
TypeScript

import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { 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<Verdict, number>;
issues: number;
findings: number;
actionable: number;
/** Overview scope, carried into every drill-down so the counts still match. */
scope: Record<string, string>;
}
/** 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<VerdictTone, string> = {
[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 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,
scope,
}: ExploitabilityChartProps) => {
return (
<Card
variant="base"
className="flex min-h-[372px] w-full min-w-0 flex-col lg:max-w-[312px]"
>
<CardHeader className="flex items-baseline justify-between gap-3">
<CardTitle>Exploitability</CardTitle>
<span className="text-text-neutral-tertiary text-xs">
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
</span>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<Link
href={issuesHref(scope, {
"filter[verdict__in]": "exploitable,likely",
})}
aria-label={`${actionable} exploitable or likely issues`}
className="border-bg-fail/35 bg-bg-fail/8 hover:bg-bg-fail/15 focus-visible:ring-ring/50 flex items-center gap-3 rounded-lg border px-4 py-3.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<TriangleAlert className="text-bg-fail size-5 shrink-0" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-text-neutral-primary text-sm font-medium">
Exploitable or likely
</span>
<span className="text-text-neutral-tertiary text-xs">
{counts.exploitable} exploitable · {counts.likely} likely
</span>
</span>
<span className="text-text-neutral-primary text-[28px] leading-8 font-light tracking-tight tabular-nums">
{actionable.toLocaleString()}
</span>
</Link>
<Card variant="inner" padding="none" className="gap-0 px-4 py-0.5">
<ul className="divide-border-neutral-secondary flex flex-col divide-y">
{REST.map((id) => {
const verdict = VERDICTS.find((item) => item.id === id);
return (
<li key={id}>
<Link
href={issuesHref(scope, { "filter[verdict]": id })}
aria-label={`${counts[id]} ${(verdict?.label ?? id).toLowerCase()} ${counts[id] === 1 ? "issue" : "issues"}`}
className="text-text-neutral-secondary hover:text-text-neutral-primary focus-visible:ring-ring/50 flex h-10 items-center gap-2.5 rounded-md text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span
aria-hidden
className={cn(
"size-2 shrink-0 rounded-full",
verdict && TONE_DOT[verdict.tone],
)}
/>
<span className="flex-1">{verdict?.label ?? id}</span>
<span className="text-text-neutral-primary font-semibold tabular-nums">
{counts[id].toLocaleString()}
</span>
</Link>
</li>
);
})}
</ul>
</Card>
{/* The fold. Without the findings count the issue count says nothing
about how much noise was removed, so the two are always shown
together. */}
<p className="border-border-neutral-secondary text-text-neutral-tertiary mt-auto border-t pt-3 text-xs">
{findings.toLocaleString()} failed findings folded into{" "}
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
</p>
</CardContent>
</Card>
);
};