feat(ui): redesign the Exploitability card and the issues verdict filter (#13093)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-19 17:57:15 +02:00
1 parent 414dd64545
commit 76cde39b08
13 files changed
+342 -245

No files matched your search

@@ -6,13 +6,14 @@ export function ExploitabilityChartSkeleton() {
variant="base"
className="flex min-h-[372px] w-full min-w-0 flex-col lg:max-w-[312px]"
>
<CardHeader>
<Skeleton className="h-7 w-[170px] rounded-xl" />
<CardHeader className="flex items-baseline justify-between gap-3">
<Skeleton className="h-7 w-[140px] rounded-xl" />
<Skeleton className="h-4 w-[56px] rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between gap-4">
<Skeleton className="h-[84px] w-[160px] rounded-xl" />
<Skeleton className="h-[120px] w-full rounded-xl" />
<Skeleton className="h-4 w-full rounded-xl" />
<CardContent className="flex flex-1 flex-col gap-3">
<Skeleton className="h-[70px] w-full rounded-lg" />
<Skeleton className="h-[124px] w-full rounded-xl" />
<Skeleton className="mt-auto h-4 w-full rounded-xl" />
</CardContent>
</Card>
);
@@ -17,11 +17,11 @@ describe("ExploitabilityChart", () => {
issues={12}
findings={37}
actionable={3}
notExploitableFindings={17}
scope={{ "filter[provider_type__in]": "aws" }}
/>,
);
expect(screen.getByText("12 issues")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "3 exploitable or likely issues" }),
).toHaveAttribute(
@@ -30,12 +30,17 @@ describe("ExploitabilityChart", () => {
);
expect(screen.getByText("0 exploitable · 3 likely")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Not exploitable/ }),
screen.getByRole("link", { name: "4 not exploitable issues" }),
).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=not_exploitable",
);
expect(screen.getByText("covers 17 findings")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "4 conditional issues" }),
).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=conditional",
);
expect(
screen.getByText("37 failed findings folded into 12 issues"),
).toBeInTheDocument();
@@ -1,16 +1,10 @@
import { TriangleAlert, Layers } from "lucide-react";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import {
Button,
ResourceStatsCard,
CardVariant,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn";
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 {
@@ -18,98 +12,105 @@ interface ExploitabilityChartProps {
issues: number;
findings: number;
actionable: number;
notExploitableFindings: 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: { id: Verdict; label: string }[] = [
{ id: "conditional", label: "Conditional" },
{ id: "unknown", label: "Unknown" },
{ id: "not_exploitable", label: "Not exploitable" },
];
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 — in the critical red. Everything else is work, not an incident, so
* it sits in a quieter box underneath. Every number links into the issue list,
* so nothing here is a dead end.
* 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,
notExploitableFindings,
scope,
}: ExploitabilityChartProps) => {
return (
<Card
variant="base"
className="flex min-h-[372px] w-full min-w-0 flex-col justify-between lg:max-w-[312px]"
className="flex min-h-[372px] w-full min-w-0 flex-col lg:max-w-[312px]"
>
<CardHeader>
<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 justify-between gap-4">
<Button asChild variant="link" size="link-sm">
<Link
href={issuesHref(scope, {
"filter[verdict__in]": "exploitable,likely",
})}
aria-label={`${actionable} exploitable or likely issues`}
>
<ResourceStatsCard
containerless
badge={{
icon: TriangleAlert,
count: actionable,
variant: CardVariant.fail,
}}
label="Exploitable or likely"
stats={[
{
icon: Layers,
label: `${counts.exploitable} exploitable · ${counts.likely} likely`,
},
]}
/>
</Link>
</Button>
<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="md">
<ul className="flex flex-col gap-3 text-xs">
{REST.map(({ id, label }) => (
<li key={id}>
<Button asChild variant="link" size="link-sm">
<Link href={issuesHref(scope, { "filter[verdict]": id })}>
<span>{label}</span>
<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]}
{counts[id].toLocaleString()}
</span>
</Link>
</Button>
{id === "not_exploitable" ? (
<span className="text-text-neutral-tertiary block text-xs">
covers {notExploitableFindings.toLocaleString()} finding
{notExploitableFindings === 1 ? "" : "s"}
</span>
) : null}
</li>
))}
</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="text-text-neutral-tertiary text-xs">
<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
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
</p>
</CardContent>
</Card>
@@ -9,9 +9,9 @@ export function TopIssuesSkeleton() {
aria-label="Loading top issues"
>
<Skeleton className="h-7 w-32 rounded-xl" />
<CardContent className="mt-4 flex flex-col gap-3">
<CardContent className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-[52px] w-full rounded-xl" />
<Skeleton key={index} className="h-[88px] w-full rounded-xl" />
))}
</CardContent>
</Card>
@@ -13,6 +13,11 @@ interface TopIssuesProps {
scope: Record<string, string>;
}
/**
* The worst issues in scope, each on its own tile. Two tiles per row once the
* charts column is wide enough; the list is short, so a single column would
* leave most of the card empty on desktop.
*/
export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
<Card variant="base" className="w-full min-w-0">
<CardTitle>Top issues</CardTitle>
@@ -20,20 +25,20 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
{issues === null ? (
<IssuesLoadError />
) : issues.length > 0 ? (
<ul className="flex flex-col gap-2">
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2">
{issues.map(({ id, attributes, relationships }) => {
const verdict = VERDICTS.find(
(item) => item.id === attributes.verdict,
);
const providerId = relationships?.provider?.data?.id;
return (
<li key={id}>
<div className="flex flex-col gap-2 py-2">
<div className="flex flex-wrap items-center gap-3">
<li key={id} className="min-w-0">
<Card variant="inner" padding="md" className="h-full gap-2">
<div className="flex items-center justify-between gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-secondary text-sm">
<span className="text-text-neutral-tertiary shrink-0 text-xs">
{attributes.findings_count}{" "}
{attributes.findings_count === 1 ? "finding" : "findings"}
</span>
@@ -61,7 +66,7 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
{attributes.account}
</p>
</div>
</div>
</Card>
</li>
);
})}
@@ -29,7 +29,6 @@ export const ExploitabilityChartSSR = async ({
issues={attributes.issues ?? 0}
findings={attributes.findings ?? 0}
actionable={attributes.actionable ?? 0}
notExploitableFindings={attributes.not_exploitable_findings ?? 0}
scope={filters}
/>
);
@@ -2,7 +2,6 @@ import { ReactNode } from "react";
import { getIssues, getIssuesOverview } from "@/actions/issues";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { IssuesSummary } from "@/components/issues/issues-summary";
import { IssuesTable } from "@/components/issues/issues-table";
import { VerdictFilter } from "@/components/issues/verdict-filter";
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
@@ -26,7 +25,7 @@ export const IssuesList = async ({
filters: filterControls,
}: {
searchParams: SearchParamsProps;
/** The scope selectors, rendered between the verdict chips and the table. */
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
filters?: ReactNode;
}) => {
const filters: Record<string, string> = {};
@@ -62,22 +61,24 @@ export const IssuesList = async ({
return (
<>
{attributes ? (
<IssuesSummary overview={attributes} />
) : (
{attributes ? null : (
<IssuesLoadError message="Failed to load issue summary." />
)}
{filterControls}
<VerdictFilter
summary={
attributes
? { counts: verdictCounts(attributes), total: attributes.issues }
? {
counts: verdictCounts(attributes),
total: attributes.issues,
findings: attributes.findings,
}
: undefined
}
/>
{filterControls}
{rows === null ? (
<IssuesLoadError />
) : rows.length ? (
+3 -2
View File
@@ -49,8 +49,9 @@ export default async function Issues({
<IssuesList
searchParams={resolvedSearchParams}
filters={
// Same layout as the overview's selector row.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
// The three scope selectors share one row on desktop, like the
// other list pages.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ProviderAccountSelectors
providers={providersData?.data ?? []}
/>