feat(ui): bring the issues UI closer to the original prototype (#13154)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Rubén De la Torre Vicoandalejandrobailo authored and GitHub committed 2026-10-26 17:24:52 +01:00
1 parent 63b8c8969c
commit 41a2bf605a
17 files changed
+495 -43

No files matched your search

@@ -149,6 +149,22 @@ describe("IssueDetail", () => {
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
});
it("links each affected resource to it on the Resources page", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
});
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
expect(
await screen.findByRole("link", {
name: "Open in Resources: arn:aws:s3:::bucket",
}),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
);
});
it("says so when the resources cannot be loaded", async () => {
getIssueMock.mockResolvedValue(undefined);
render(<IssueDetail id="issue-1" issue={issue} />);
+21 -1
View File
@@ -52,6 +52,15 @@ const STATE_LABELS: Record<LegState, string> = {
};
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueDetailProps {
id: string;
issue: IssueAttributes;
@@ -250,8 +259,19 @@ export const IssueDetail = ({
</h4>
<ul className="flex flex-col gap-2">
{state.resources.slice(0, visibleResources).map((uid) => (
<li key={uid} className="min-w-0">
<li
key={uid}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, state.providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</li>
))}
</ul>
+9 -1
View File
@@ -12,6 +12,7 @@ import {
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMediaQuery } from "@/hooks/use-media-query";
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
import { GraphNode } from "@/types/attack-paths";
import { IssueAttributes, IssueFinding } from "@/types/issues";
@@ -34,6 +35,11 @@ interface UnscopedIssueGraphProps extends IssueGraphBaseProps {
type IssueGraphProps = ScopedIssueGraphProps | UnscopedIssueGraphProps;
/** Below this the node labels (11px) stop being legible; past it, pan. */
const ISSUE_GRAPH_MIN_ZOOM = 0.8;
/** A phone cannot fit even one resource and its finding at that zoom: fit all. */
const READABLE_ZOOM_MEDIA_QUERY = "(min-width: 768px)";
export const IssueGraph = ({
issue,
resourceUids,
@@ -42,6 +48,7 @@ export const IssueGraph = ({
scanId,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
const view = buildIssueGraph(issue, resourceUids, findings);
const shownFindings = new Set(
view.nodes.flatMap((node) =>
@@ -149,13 +156,14 @@ export const IssueGraph = ({
<Card
variant="inner"
padding="none"
className="h-[420px] overflow-hidden"
className="h-[560px] overflow-hidden"
>
<AttackPathGraph
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
{finding.findingDetails && (
@@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { IssuesSummary } from "@/components/issues/issues-summary";
import { IssuesOverviewAttributes } from "@/types/issues";
const summary: IssuesOverviewAttributes = {
issues: 13,
findings: 41,
actionable: 4,
not_exploitable_findings: 1,
exploitable: 1,
likely: 3,
conditional: 5,
not_exploitable: 2,
unknown: 2,
};
describe("IssuesSummary", () => {
it("leads with the reduction and links every tile into the scoped list", () => {
render(
<IssuesSummary
summary={summary}
scope={{ "filter[provider_type__in]": "aws" }}
/>,
);
expect(
screen.getByRole("link", { name: "41 Failed findings folded" }),
).toHaveAttribute("href", "/issues?filter%5Bprovider_type__in%5D=aws");
expect(screen.getByRole("link", { name: "13 Issues" })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "4 Exploitable or likely" }),
).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict__in%5D=exploitable%2Clikely",
);
expect(
screen.getByRole("link", {
name: "2 Not exploitable, 1 finding covered by a control",
}),
).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=not_exploitable",
);
expect(screen.getByRole("link", { name: "2 Unknown" })).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=unknown",
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import {
CircleHelp,
Layers,
type LucideIcon,
Shield,
ShieldCheck,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { CardVariant, ResourceStatsCard } from "@/components/shadcn";
import { issuesHref } from "@/lib/issues/scope";
import { IssuesOverviewAttributes } from "@/types/issues";
interface TileProps {
value: number;
label: string;
detail?: string;
href: string;
icon: LucideIcon;
variant: CardVariant;
}
const Tile = ({ value, label, detail, href, icon, variant }: TileProps) => (
<Link
href={href}
aria-label={[`${value.toLocaleString()} ${label}`, detail]
.filter(Boolean)
.join(", ")}
className="focus-visible:ring-ring/50 min-w-0 rounded-xl focus-visible:ring-2 focus-visible:outline-none"
>
<ResourceStatsCard
badge={{ icon, count: value.toLocaleString(), variant }}
label={label}
stats={detail ? [{ icon: ShieldCheck, label: detail }] : []}
className="hover:border-border-neutral-primary h-full transition-colors"
/>
</Link>
);
/**
* The reduction, as the first row of the page: findings and issues side by
* side because the ratio between them is the claim the page makes. Every tile
* keeps the scope it was counted in.
*/
export const IssuesSummary = ({
summary,
scope,
}: {
summary: IssuesOverviewAttributes;
/** The filters in force other than the verdict, so the counts still match. */
scope: Record<string, string>;
}) => (
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3 2xl:grid-cols-5">
<Tile
value={summary.findings}
label="Failed findings folded"
href={issuesHref(scope)}
icon={Layers}
variant={CardVariant.fail}
/>
<Tile
value={summary.issues}
label="Issues"
href={issuesHref(scope)}
icon={Shield}
variant={CardVariant.default}
/>
<Tile
value={summary.actionable}
label="Exploitable or likely"
href={issuesHref(scope, { "filter[verdict__in]": "exploitable,likely" })}
icon={TriangleAlert}
variant={summary.actionable > 0 ? CardVariant.fail : CardVariant.pass}
/>
<Tile
value={summary.not_exploitable}
label="Not exploitable"
detail={`${summary.not_exploitable_findings.toLocaleString()} ${summary.not_exploitable_findings === 1 ? "finding" : "findings"} covered by a control`}
href={issuesHref(scope, { "filter[verdict]": "not_exploitable" })}
icon={ShieldCheck}
variant={CardVariant.pass}
/>
<Tile
value={summary.unknown}
label="Unknown"
href={issuesHref(scope, { "filter[verdict]": "unknown" })}
icon={CircleHelp}
variant={CardVariant.warning}
/>
</div>
);
@@ -93,6 +93,9 @@ describe("IssuesTable", () => {
screen.getByRole("button", { name: "Collapse Public bucket" }),
).toHaveAttribute("aria-expanded", "true");
expect(await screen.findByText("Restrict access")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open the full path" }),
).toHaveAttribute("href", "/issues/issue-1");
// When
await user.keyboard(" ");
// Then
+6
View File
@@ -2,6 +2,7 @@
import { ColumnDef, Row } from "@tanstack/react-table";
import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link";
import { createContext, useContext, useState } from "react";
import { IssueDetail } from "@/components/issues/issue-detail";
@@ -138,6 +139,11 @@ export const IssuesTable = ({
id={`issue-${row.original.id}`}
className="sticky left-0 w-[calc(100cqw-4rem)] min-w-0"
>
<Button asChild variant="link" size="link-sm">
<Link href={`/issues/${row.original.id}`}>
Open the full path
</Link>
</Button>
<IssueDetail
key={`${row.original.id}-${row.original.attributes.updated_at}`}
id={row.original.id}
@@ -341,6 +341,7 @@ describe("Issues responsive layout", () => {
issues={1}
findings={3}
actionable={1}
coveredFindings={0}
scope={{}}
/>
<TopIssues issues={[issue]} scope={{}} />
+6 -9
View File
@@ -30,9 +30,9 @@ const lastPushedParams = () =>
.searchParams;
describe("VerdictFilter", () => {
it("renders one tab per verdict with its count and the fold", () => {
it("renders one tab per verdict with its count", () => {
navigationState.searchParams = new URLSearchParams();
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
expect(screen.getByRole("tab", { name: "All, 2 issues" })).toHaveAttribute(
"aria-selected",
@@ -42,9 +42,6 @@ describe("VerdictFilter", () => {
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 () => {
@@ -52,7 +49,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"page=3&pageSize=10&filter[verdict]=likely",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
expect(
screen.getByRole("tab", { name: "Likely, 0 issues" }),
@@ -72,7 +69,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"filter[verdict__in]=exploitable,likely",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
// No single tab owns the combined filter, so none is selected...
expect(
@@ -106,7 +103,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"filter[verdict]=likely",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull();
});
@@ -117,7 +114,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"page=3&filter[pattern]=aws_public_bucket&filter[provider_id__in]=provider-1",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
// When
await user.click(
screen.getByRole("button", {
-10
View File
@@ -15,8 +15,6 @@ 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";
@@ -142,14 +140,6 @@ export function VerdictFilter({ summary }: { summary?: VerdictSummary }) {
))}
</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