mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 12:24:17 +00:00
feat(ui): redesign the Exploitability card and the issues verdict filter (#13093)
This commit is contained in:
1 parent
414dd64545
commit
76cde39b08
13 files changed
+342
-245
No files matched your search
+7
-6
@@ -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 ? (
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { CircleHelp, Layers, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { CardVariant, ResourceStatsCard } from "@/components/shadcn";
|
||||
import { IssuesOverviewResponse } from "@/types/issues";
|
||||
|
||||
/** The fold, as the tiles above the issue list. */
|
||||
export const IssuesSummary = ({
|
||||
overview,
|
||||
}: {
|
||||
overview: IssuesOverviewResponse["data"]["attributes"];
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<ResourceStatsCard
|
||||
badge={{ icon: Layers, count: overview.findings }}
|
||||
label="Failed findings folded"
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
accent={CardVariant.info}
|
||||
badge={{
|
||||
icon: Layers,
|
||||
count: overview.issues,
|
||||
variant: CardVariant.info,
|
||||
}}
|
||||
label="Issues"
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
badge={{
|
||||
icon: TriangleAlert,
|
||||
count: overview.actionable,
|
||||
variant: CardVariant.fail,
|
||||
}}
|
||||
label="Exploitable or likely"
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
badge={{
|
||||
icon: ShieldCheck,
|
||||
count: overview.not_exploitable,
|
||||
variant: CardVariant.pass,
|
||||
}}
|
||||
label="Not exploitable"
|
||||
stats={[
|
||||
{
|
||||
icon: Layers,
|
||||
label: `${overview.not_exploitable_findings} findings`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
badge={{
|
||||
icon: CircleHelp,
|
||||
count: overview.unknown,
|
||||
variant: CardVariant.warning,
|
||||
}}
|
||||
label="Unknown"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -237,7 +237,6 @@ describe("Issues responsive layout", () => {
|
||||
issues={1}
|
||||
findings={3}
|
||||
actionable={1}
|
||||
notExploitableFindings={0}
|
||||
scope={{}}
|
||||
/>
|
||||
<TopIssues issues={[issue]} scope={{}} />
|
||||
|
||||
@@ -25,41 +25,99 @@ const counts = {
|
||||
unknown: 0,
|
||||
};
|
||||
|
||||
const hrefOf = (name: RegExp) =>
|
||||
screen.getByRole("link", { name }).getAttribute("href");
|
||||
const lastPushedParams = () =>
|
||||
new URL(navigationState.push.mock.calls.at(-1)![0], "http://localhost")
|
||||
.searchParams;
|
||||
|
||||
describe("VerdictFilter", () => {
|
||||
it("drops the page when a verdict is set or cleared, keeping other params", () => {
|
||||
it("renders one tab per verdict with its count and the fold", () => {
|
||||
navigationState.searchParams = new URLSearchParams();
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "All, 2 issues" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
|
||||
).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getAllByRole("tab")).toHaveLength(6);
|
||||
expect(
|
||||
screen.getByText("9 failed findings folded into 2 issues"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the verdict tab and resets the page, keeping other params", async () => {
|
||||
const user = userEvent.setup();
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"page=3&pageSize=10&filter[verdict]=likely",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
|
||||
const set = new URLSearchParams(hrefOf(/^exploitable 2$/i)!.split("?")[1]);
|
||||
expect(set.get("page")).toBeNull();
|
||||
expect(set.get("pageSize")).toBe("10");
|
||||
expect(set.get("filter[verdict]")).toBe("exploitable");
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Likely, 0 issues" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await user.click(
|
||||
screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
|
||||
);
|
||||
|
||||
const cleared = new URLSearchParams(hrefOf(/likely/i)!.split("?")[1]);
|
||||
expect(cleared.get("page")).toBeNull();
|
||||
expect(cleared.get("filter[verdict]")).toBeNull();
|
||||
const params = lastPushedParams();
|
||||
expect(params.get("page")).toBe("1");
|
||||
expect(params.get("pageSize")).toBe("10");
|
||||
expect(params.get("filter[verdict]")).toBe("exploitable");
|
||||
});
|
||||
|
||||
it("offers an All chip that clears the verdict and is active without one", () => {
|
||||
it("marks the tabs a combined filter covers and lets All replace it", async () => {
|
||||
const user = userEvent.setup();
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"filter[verdict__in]=exploitable,likely",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
|
||||
// No single tab owns the combined filter, so none is selected...
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("tab")
|
||||
.every((tab) => tab.getAttribute("aria-selected") === "false"),
|
||||
).toBe(true);
|
||||
// ...but the covered tabs are marked, and there is no chip for it.
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
|
||||
).toHaveAttribute("data-highlighted", "true");
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Likely, 0 issues" }),
|
||||
).toHaveAttribute("data-highlighted", "true");
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Conditional, 0 issues" }),
|
||||
).not.toHaveAttribute("data-highlighted");
|
||||
expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull();
|
||||
expect(screen.getByRole("tablist")).toHaveAccessibleDescription(
|
||||
"Showing Exploitable and Likely issues",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "All, 2 issues" }));
|
||||
|
||||
expect(navigationState.push).toHaveBeenLastCalledWith("/issues", {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no chip strip for a verdict picked from the tabs", () => {
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"filter[verdict]=likely",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
|
||||
expect(hrefOf(/^all 2$/i)).toBe("/issues");
|
||||
expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the pattern filter and lets the user remove it", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"page=3&filter[pattern]=aws_public_bucket&filter[provider_id__in]=provider-1",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -67,14 +125,9 @@ describe("VerdictFilter", () => {
|
||||
}),
|
||||
);
|
||||
// Then
|
||||
const target = new URL(
|
||||
navigationState.push.mock.calls.at(-1)![0],
|
||||
"http://localhost",
|
||||
);
|
||||
expect(target.searchParams.has("filter[pattern]")).toBe(false);
|
||||
expect(target.searchParams.get("filter[provider_id__in]")).toBe(
|
||||
"provider-1",
|
||||
);
|
||||
expect(target.searchParams.get("page")).toBe("1");
|
||||
const target = lastPushedParams();
|
||||
expect(target.has("filter[pattern]")).toBe(false);
|
||||
expect(target.get("filter[provider_id__in]")).toBe("provider-1");
|
||||
expect(target.get("page")).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -1,104 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ComponentProps, useId } from "react";
|
||||
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Badge, Tabs, TabsList, TabsTrigger } from "@/components/shadcn";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { VERDICTS } from "@/lib/issues/verdicts";
|
||||
import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Verdict } from "@/types/issues";
|
||||
|
||||
interface VerdictSummary {
|
||||
counts: Record<Verdict, number>;
|
||||
/** Issues in scope, whatever their verdict. */
|
||||
total: number;
|
||||
/** Failed findings those issues fold. */
|
||||
findings: number;
|
||||
}
|
||||
|
||||
const ALL_TAB = "all";
|
||||
|
||||
/** The active tab takes the verdict's colour instead of the default accent. */
|
||||
const TONE_UNDERLINE: Record<VerdictTone, string> = {
|
||||
[VERDICT_TONE.fail]: "after:bg-bg-fail",
|
||||
[VERDICT_TONE.warning]: "after:bg-bg-data-medium",
|
||||
[VERDICT_TONE.pass]: "after:bg-bg-pass",
|
||||
[VERDICT_TONE.neutral]: "after:bg-bg-data-muted",
|
||||
};
|
||||
|
||||
const TONE_BADGE: Record<VerdictTone, ComponentProps<typeof Badge>["variant"]> =
|
||||
{
|
||||
[VERDICT_TONE.fail]: "error",
|
||||
[VERDICT_TONE.warning]: "warning",
|
||||
[VERDICT_TONE.pass]: "success",
|
||||
[VERDICT_TONE.neutral]: "tag",
|
||||
};
|
||||
|
||||
/**
|
||||
* A combined verdict filter (the overview's "exploitable or likely" link)
|
||||
* selects no single tab, so the tabs it covers are marked by hand.
|
||||
*/
|
||||
const HIGHLIGHT = "text-slate-900 after:scale-x-100 dark:text-white";
|
||||
|
||||
/** Filters the issue list understands but no control on this page sets. */
|
||||
const EXTRA_FILTER_LABELS = {
|
||||
"filter[pattern]": "Pattern",
|
||||
"filter[cloud]": "Cloud",
|
||||
"filter[severity]": "Severity",
|
||||
"filter[provider_type]": "Provider type",
|
||||
"filter[provider_alias]": "Account",
|
||||
"filter[verdict__in]": "Verdicts",
|
||||
} as const;
|
||||
|
||||
export function VerdictFilter({ summary }: { summary?: VerdictSummary }) {
|
||||
const searchParams = useSearchParams();
|
||||
const { clearFilter } = useUrlFilters();
|
||||
const { clearFilter, navigateWithParams } = useUrlFilters();
|
||||
const active = searchParams.get("filter[verdict]");
|
||||
const multiple = searchParams.get("filter[verdict__in]");
|
||||
const combined = searchParams.get("filter[verdict__in]")?.split(",") ?? [];
|
||||
const value = combined.length ? "" : (active ?? ALL_TAB);
|
||||
// No tab is selected under a combined filter, so the tab list says what
|
||||
// the list is showing.
|
||||
const combinedLabels = combined.map(
|
||||
(id) => VERDICTS.find((verdict) => verdict.id === id)?.label ?? id,
|
||||
);
|
||||
const descriptionId = useId();
|
||||
|
||||
const hrefFor = (verdict: Verdict | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("page");
|
||||
params.delete("filter[verdict__in]");
|
||||
if (verdict === null || active === verdict)
|
||||
params.delete("filter[verdict]");
|
||||
else params.set("filter[verdict]", verdict);
|
||||
return params.size ? `/issues?${params}` : "/issues";
|
||||
const handleChange = (next: string) => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete("filter[verdict__in]");
|
||||
if (next === ALL_TAB) params.delete("filter[verdict]");
|
||||
else params.set("filter[verdict]", next);
|
||||
});
|
||||
};
|
||||
|
||||
const options = [
|
||||
{ id: null, label: "All", count: summary?.total },
|
||||
...VERDICTS.map(({ id, label }) => ({
|
||||
const tabs = [
|
||||
{
|
||||
id: ALL_TAB,
|
||||
label: "All",
|
||||
count: summary?.total,
|
||||
underline: "",
|
||||
badge: "tag" as const,
|
||||
highlighted: false,
|
||||
},
|
||||
...VERDICTS.map(({ id, label, tone }) => ({
|
||||
id,
|
||||
label,
|
||||
count: summary?.counts[id],
|
||||
underline: TONE_UNDERLINE[tone],
|
||||
badge: TONE_BADGE[tone],
|
||||
highlighted: combined.includes(id),
|
||||
})),
|
||||
];
|
||||
const chips = Object.entries(EXTRA_FILTER_LABELS).flatMap(([key, label]) => {
|
||||
const value = searchParams.get(key);
|
||||
if (!value) return [];
|
||||
const displayValue =
|
||||
key === "filter[verdict__in]"
|
||||
? value
|
||||
.split(",")
|
||||
.map(
|
||||
(id) =>
|
||||
VERDICTS.find((verdict) => verdict.id === id)?.label ?? id,
|
||||
)
|
||||
.join(", ")
|
||||
: value;
|
||||
return [{ key, label, value, displayValue }];
|
||||
const chipValue = searchParams.get(key);
|
||||
return chipValue ? [{ key, label, value: chipValue }] : [];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Filter issues by verdict"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isActive =
|
||||
!multiple &&
|
||||
(active === option.id || (!active && option.id === null));
|
||||
return (
|
||||
<Button
|
||||
key={option.id ?? "all"}
|
||||
asChild
|
||||
variant={isActive ? "default" : "outline"}
|
||||
size="sm"
|
||||
<Tabs value={value} onValueChange={handleChange}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
{combinedLabels.length ? (
|
||||
<p id={descriptionId} className="sr-only">
|
||||
Showing {combinedLabels.join(" and ")} issues
|
||||
</p>
|
||||
) : null}
|
||||
<TabsList
|
||||
aria-label="Filter issues by verdict"
|
||||
aria-describedby={
|
||||
combinedLabels.length ? descriptionId : undefined
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={hrefFor(option.id)}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
scroll={false}
|
||||
>
|
||||
{option.label}
|
||||
{option.count === undefined ? "" : ` ${option.count}`}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FilterSummaryStrip
|
||||
chips={chips}
|
||||
onRemove={(key) => clearFilter(key)}
|
||||
trailingContent={<ClearFiltersButton />}
|
||||
/>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
data-highlighted={tab.highlighted || undefined}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
tab.underline,
|
||||
tab.highlighted && HIGHLIGHT,
|
||||
)}
|
||||
aria-label={
|
||||
tab.count === undefined
|
||||
? undefined
|
||||
: `${tab.label}, ${tab.count.toLocaleString()} ${tab.count === 1 ? "issue" : "issues"}`
|
||||
}
|
||||
adornment={
|
||||
tab.count === undefined ? undefined : (
|
||||
<Badge variant={tab.badge} size="sm">
|
||||
{tab.count.toLocaleString()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
{summary ? (
|
||||
// The fold: how much noise the issue count removed.
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{summary.findings.toLocaleString()} failed findings folded into{" "}
|
||||
{summary.total.toLocaleString()}{" "}
|
||||
{summary.total === 1 ? "issue" : "issues"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Tabs>
|
||||
{/* Only filters that arrived by link get a chip; the tabs and the scope
|
||||
selectors are their own controls, so they never need one. */}
|
||||
{chips.length ? (
|
||||
<FilterSummaryStrip
|
||||
chips={chips}
|
||||
onRemove={(key) => clearFilter(key)}
|
||||
trailingContent={<ClearFiltersButton />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,49 @@ import { ComponentProps } from "react";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { LegName, LegState, Verdict } from "@/types/issues";
|
||||
|
||||
/** The colour family a verdict reads in, shared by the overview card and the tabs. */
|
||||
export const VERDICT_TONE = {
|
||||
fail: "fail",
|
||||
warning: "warning",
|
||||
pass: "pass",
|
||||
neutral: "neutral",
|
||||
} as const;
|
||||
|
||||
export type VerdictTone = (typeof VERDICT_TONE)[keyof typeof VERDICT_TONE];
|
||||
|
||||
interface VerdictDisplay {
|
||||
id: Verdict;
|
||||
label: string;
|
||||
variant: ComponentProps<typeof Badge>["variant"];
|
||||
tone: VerdictTone;
|
||||
}
|
||||
|
||||
export const VERDICTS: VerdictDisplay[] = [
|
||||
{ id: "exploitable", label: "Exploitable", variant: "error" },
|
||||
{ id: "likely", label: "Likely", variant: "error" },
|
||||
{ id: "conditional", label: "Conditional", variant: "outline" },
|
||||
{ id: "not_exploitable", label: "Not exploitable", variant: "success" },
|
||||
{ id: "unknown", label: "Unknown", variant: "tag" },
|
||||
{
|
||||
id: "exploitable",
|
||||
label: "Exploitable",
|
||||
variant: "error",
|
||||
tone: VERDICT_TONE.fail,
|
||||
},
|
||||
{ id: "likely", label: "Likely", variant: "error", tone: VERDICT_TONE.fail },
|
||||
{
|
||||
id: "conditional",
|
||||
label: "Conditional",
|
||||
variant: "outline",
|
||||
tone: VERDICT_TONE.warning,
|
||||
},
|
||||
{
|
||||
id: "not_exploitable",
|
||||
label: "Not exploitable",
|
||||
variant: "success",
|
||||
tone: VERDICT_TONE.pass,
|
||||
},
|
||||
{
|
||||
id: "unknown",
|
||||
label: "Unknown",
|
||||
variant: "tag",
|
||||
tone: VERDICT_TONE.neutral,
|
||||
},
|
||||
];
|
||||
|
||||
const LEG_GOOD_WHEN_YES: Record<LegName, boolean> = {
|
||||
|
||||
Reference in new issue
Block a user