diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx
index 76ad0e3d31..479f4cc654 100644
--- a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx
+++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx
@@ -17,6 +17,7 @@ describe("ExploitabilityChart", () => {
issues={12}
findings={37}
actionable={3}
+ coveredFindings={0}
scope={{ "filter[provider_type__in]": "aws" }}
/>,
);
@@ -45,4 +46,38 @@ describe("ExploitabilityChart", () => {
screen.getByText("37 failed findings folded into 12 issues"),
).toBeInTheDocument();
});
+
+ it("draws each verdict's share and names the findings a control covers", () => {
+ render(
+ ,
+ );
+
+ // Sized by share, so the bar reads the same whatever the estate's size.
+ expect(screen.getByTitle("4 not exploitable")).toHaveStyle({
+ width: "50%",
+ });
+ expect(screen.getByTitle("1 exploitable")).toHaveAttribute(
+ "href",
+ "/issues?filter%5Bverdict%5D=exploitable",
+ );
+ // A verdict with no issues gets no segment.
+ expect(screen.queryByTitle("0 conditional")).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "9 findings" })).toHaveAttribute(
+ "href",
+ "/issues?filter%5Bverdict%5D=not_exploitable",
+ );
+ });
});
diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx
index a30a3093b6..56eecb1ba0 100644
--- a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx
+++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx
@@ -1,7 +1,13 @@
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
+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";
@@ -12,6 +18,8 @@ interface ExploitabilityChartProps {
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;
}
@@ -27,6 +35,15 @@ const TONE_DOT: Record = {
[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.
*
@@ -40,8 +57,11 @@ export const ExploitabilityChart = ({
issues,
findings,
actionable,
+ coveredFindings,
scope,
}: ExploitabilityChartProps) => {
+ const barTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
+
return (
+ {/* 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 && (
+
+ {(Object.keys(BAR_SEGMENT) as Verdict[])
+ .filter((id) => counts[id] > 0)
+ .map((id) => (
+ item.id === id)?.label ?? id).toLowerCase()}`}
+ style={{ width: `${(counts[id] / barTotal) * 100}%` }}
+ className={cn(
+ "h-full transition-opacity hover:opacity-80",
+ BAR_SEGMENT[id],
+ )}
+ />
+ ))}
+
+ )}
+
{REST.map((id) => {
@@ -111,6 +157,22 @@ export const ExploitabilityChart = ({
{findings.toLocaleString()} failed findings folded into{" "}
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
+ {coveredFindings > 0 && (
+ <>
+ {" · "}
+ {" "}
+ already covered by a control
+ >
+ )}
diff --git a/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx
index 3e7aeb848f..be9df6edb6 100644
--- a/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx
+++ b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx
@@ -29,6 +29,7 @@ export const ExploitabilityChartSSR = async ({
issues={attributes.issues ?? 0}
findings={attributes.findings ?? 0}
actionable={attributes.actionable ?? 0}
+ coveredFindings={attributes.not_exploitable_findings ?? 0}
scope={filters}
/>
);
diff --git a/ui/app/(prowler)/issues/[id]/page.test.tsx b/ui/app/(prowler)/issues/[id]/page.test.tsx
new file mode 100644
index 0000000000..1564ae0818
--- /dev/null
+++ b/ui/app/(prowler)/issues/[id]/page.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen } from "@testing-library/react";
+import { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { getIssue, notFound } = vi.hoisted(() => ({
+ getIssue: vi.fn(),
+ notFound: vi.fn(() => {
+ throw new Error("NOT_FOUND");
+ }),
+}));
+vi.mock("next/navigation", () => ({ notFound }));
+vi.mock("@/actions/issues", () => ({ getIssue }));
+vi.mock("@/components/shadcn/content-layout", () => ({
+ ContentLayout: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("@/components/shared/feature-flag-gate", () => ({
+ FeatureFlagGate: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("@/components/issues/issue-detail", () => ({
+ IssueDetail: ({ scanId }: { scanId?: string }) => (
+ {scanId}
+ ),
+}));
+
+import IssuePage from "./page";
+
+const params = Promise.resolve({ id: "issue-1" });
+
+describe("IssuePage", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ getIssue.mockReset();
+ });
+
+ it("returns not found in Local Server without asking the API", async () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+ await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
+ expect(getIssue).not.toHaveBeenCalled();
+ });
+
+ it("returns not found when the API has no such issue", async () => {
+ getIssue.mockResolvedValue({ error: "Not found", status: 404 });
+ await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
+ });
+
+ it("shows the issue with the detail the list opens in place", async () => {
+ getIssue.mockResolvedValue({
+ data: {
+ type: "issues",
+ id: "issue-1",
+ attributes: {
+ pattern: "aws_public_bucket",
+ cloud: "aws",
+ account: "prod",
+ title: "Public bucket",
+ verdict: "likely",
+ },
+ relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
+ },
+ });
+
+ render(await IssuePage({ params }));
+
+ expect(getIssue).toHaveBeenCalledWith("issue-1");
+ expect(
+ screen.getByRole("heading", { name: "Public bucket" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Likely")).toBeInTheDocument();
+ expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
+ expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
+ "href",
+ "/issues",
+ );
+ });
+});
diff --git a/ui/app/(prowler)/issues/[id]/page.tsx b/ui/app/(prowler)/issues/[id]/page.tsx
new file mode 100644
index 0000000000..b94e3cb06e
--- /dev/null
+++ b/ui/app/(prowler)/issues/[id]/page.tsx
@@ -0,0 +1,76 @@
+import { ShieldAlert } from "lucide-react";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { getIssue } from "@/actions/issues";
+import { IssueDetail } from "@/components/issues/issue-detail";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import { Card } from "@/components/shadcn/card/card";
+import { ContentLayout } from "@/components/shadcn/content-layout";
+import { EntityInfo } from "@/components/shadcn/entities";
+import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
+import { FEATURE_FLAG } from "@/lib/feature-flags";
+import { VERDICTS } from "@/lib/issues/verdicts";
+import { isCloud } from "@/lib/shared/env";
+
+/**
+ * One issue on its own URL, so a path can be shared or linked from a ticket.
+ * The body is the same detail the list opens in place.
+ */
+export default async function IssuePage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ if (!isCloud()) notFound();
+ const { id } = await params;
+ const issue = (await getIssue(id))?.data;
+ // handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
+ if (!issue?.attributes) notFound();
+
+ const { attributes, relationships } = issue;
+ const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
+
+ return (
+ }>
+
+ Issues is not available for this account yet.
+
+ }
+ >
+
+
+
+
+
+ {verdict?.label ?? attributes.verdict}
+
+
+ {attributes.pattern}
+
+
+
+ {attributes.title}
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/app/(prowler)/issues/_components/issues-list.tsx b/ui/app/(prowler)/issues/_components/issues-list.tsx
index 9e784053da..ce7ce9d3ea 100644
--- a/ui/app/(prowler)/issues/_components/issues-list.tsx
+++ b/ui/app/(prowler)/issues/_components/issues-list.tsx
@@ -2,6 +2,7 @@ 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";
@@ -36,17 +37,16 @@ export const IssuesList = async ({
if (typeof value === "string" && value) filters[key] = value;
});
+ // The counts are deliberately NOT filtered by verdict: the ladder has to
+ // keep showing every verdict's total, or picking one would zero the rest
+ // and there would be no way back.
+ const scope = Object.fromEntries(
+ Object.entries(filters).filter(
+ ([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
+ ),
+ );
const [summary, issues] = await Promise.all([
- // The counts are deliberately NOT filtered by verdict: the ladder has to
- // keep showing every verdict's total, or picking one would zero the rest
- // and there would be no way back.
- getIssuesOverview({
- filters: Object.fromEntries(
- Object.entries(filters).filter(
- ([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
- ),
- ),
- }),
+ getIssuesOverview({ filters: scope }),
getIssues({
filters,
page: Number(searchParams.page) || 1,
@@ -63,7 +63,11 @@ export const IssuesList = async ({
return (
<>
- {attributes ? null : (
+ {attributes ? (
+
+
+
+ ) : (
)}
@@ -75,7 +79,6 @@ export const IssuesList = async ({
? {
counts: verdictCounts(attributes),
total: attributes.issues,
- findings: attributes.findings,
}
: undefined
}
diff --git a/ui/components/attack-paths/graph/attack-path-graph.tsx b/ui/components/attack-paths/graph/attack-path-graph.tsx
index 96f13cb115..44670e98cd 100644
--- a/ui/components/attack-paths/graph/attack-path-graph.tsx
+++ b/ui/components/attack-paths/graph/attack-path-graph.tsx
@@ -76,6 +76,9 @@ interface AttackPathGraphProps {
onNodeClick?: (node: GraphNode) => void;
onNodeDoubleClick?: (node: GraphNode) => void;
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
+ // Floor for the automatic fit: a graph that would shrink below it is shown
+ // at this zoom and panned instead, so its labels stay readable.
+ minFitZoom?: number;
ref?: Ref;
className?: string;
}
@@ -194,6 +197,13 @@ const AUTO_FIT_OPTIONS = {
duration: 300,
} as const;
+// With a zoom floor the graph can be larger than the canvas and is centred on
+// it, so the minimap's lopsided padding would only push the top out of view.
+const fitOptionsFor = (minFitZoom?: number) =>
+ minFitZoom === undefined
+ ? AUTO_FIT_OPTIONS
+ : { ...AUTO_FIT_OPTIONS, padding: "32px" as const, minZoom: minFitZoom };
+
const MEASURED_FIT_MAX_ATTEMPTS = 30;
const scheduleMeasuredFit = (
@@ -230,6 +240,7 @@ const GraphCanvas = ({
onNodeClick,
onNodeDoubleClick,
onInitialFilter,
+ minFitZoom,
ref,
}: GraphCanvasProps) => {
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
@@ -265,9 +276,9 @@ const GraphCanvas = ({
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
);
},
- () => fitView(AUTO_FIT_OPTIONS),
+ () => fitView(fitOptionsFor(minFitZoom)),
);
- }, [expandedClasses, fitView, getNodes]);
+ }, [expandedClasses, fitView, getNodes, minFitZoom]);
// --- initialNodeId: synchronous filtered-view derivation on first render ---
// Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
@@ -352,15 +363,15 @@ const GraphCanvas = ({
);
if (contextualNodes.length > 0) {
- fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
+ fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
return;
}
}
- fitView(AUTO_FIT_OPTIONS);
+ fitView(fitOptionsFor(minFitZoom));
},
);
- }, [expanded, isFilteredView, fitView, getNodes]);
+ }, [expanded, isFilteredView, fitView, getNodes, minFitZoom]);
useEffect(() => {
const previous = previousExpandedRef.current;
@@ -384,7 +395,7 @@ const GraphCanvas = ({
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
);
},
- () => fitView(AUTO_FIT_OPTIONS),
+ () => fitView(fitOptionsFor(minFitZoom)),
);
}
@@ -415,10 +426,10 @@ const GraphCanvas = ({
const contextualNodes = getNodes().filter((n) =>
contextualFitNodeIds.has(n.id),
);
- fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
+ fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
},
);
- }, [expanded, fitView, getNodes]);
+ }, [expanded, fitView, getNodes, minFitZoom]);
// Cloud reshapes into the attack-path view: drop the account hub, collapse
// each resource class into one expandable node, and inject the outcome node.
@@ -592,7 +603,10 @@ const GraphCanvas = ({
onNodeMouseEnter={handleNodeMouseEnter}
onNodeMouseLeave={handleNodeMouseLeave}
fitView
- fitViewOptions={{ ...AUTO_FIT_OPTIONS, includeHiddenNodes: true }}
+ fitViewOptions={{
+ ...fitOptionsFor(minFitZoom),
+ includeHiddenNodes: true,
+ }}
// Supported React Flow behavior: wheel over the graph zooms the
// viewport. The surrounding UX avoids using node details as inline
// content, so this no longer fights a below-graph details section.
@@ -646,6 +660,7 @@ export const AttackPathGraph = ({
onNodeClick,
onNodeDoubleClick,
onInitialFilter,
+ minFitZoom,
ref,
className,
}: AttackPathGraphProps) => {
@@ -674,6 +689,7 @@ export const AttackPathGraph = ({
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onInitialFilter={onInitialFilter}
+ minFitZoom={minFitZoom}
/>
diff --git a/ui/components/issues/issue-detail.test.tsx b/ui/components/issues/issue-detail.test.tsx
index 9eaa8be532..62f7bb0067 100644
--- a/ui/components/issues/issue-detail.test.tsx
+++ b/ui/components/issues/issue-detail.test.tsx
@@ -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();
+
+ 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();
diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx
index 5c40894bc1..585d35c94a 100644
--- a/ui/components/issues/issue-detail.tsx
+++ b/ui/components/issues/issue-detail.tsx
@@ -52,6 +52,15 @@ const STATE_LABELS: Record = {
};
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 = ({
{state.resources.slice(0, visibleResources).map((uid) => (
- -
+
-
+
))}
diff --git a/ui/components/issues/issue-graph.tsx b/ui/components/issues/issue-graph.tsx
index 9704831c4d..6d9a424c67 100644
--- a/ui/components/issues/issue-graph.tsx
+++ b/ui/components/issues/issue-graph.tsx
@@ -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 = ({
{finding.findingDetails && (
diff --git a/ui/components/issues/issues-summary.test.tsx b/ui/components/issues/issues-summary.test.tsx
new file mode 100644
index 0000000000..ffcb7a0f34
--- /dev/null
+++ b/ui/components/issues/issues-summary.test.tsx
@@ -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(
+ ,
+ );
+
+ 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",
+ );
+ });
+});
diff --git a/ui/components/issues/issues-summary.tsx b/ui/components/issues/issues-summary.tsx
new file mode 100644
index 0000000000..80bd209c42
--- /dev/null
+++ b/ui/components/issues/issues-summary.tsx
@@ -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) => (
+
+
+
+);
+
+/**
+ * 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;
+}) => (
+
+
+
+ 0 ? CardVariant.fail : CardVariant.pass}
+ />
+
+
+
+);
diff --git a/ui/components/issues/issues-table.test.tsx b/ui/components/issues/issues-table.test.tsx
index 3a8c45e8ba..adc9092632 100644
--- a/ui/components/issues/issues-table.test.tsx
+++ b/ui/components/issues/issues-table.test.tsx
@@ -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
diff --git a/ui/components/issues/issues-table.tsx b/ui/components/issues/issues-table.tsx
index 0bea60ce43..f9b0fca20b 100644
--- a/ui/components/issues/issues-table.tsx
+++ b/ui/components/issues/issues-table.tsx
@@ -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"
>
+
{
issues={1}
findings={3}
actionable={1}
+ coveredFindings={0}
scope={{}}
/>
diff --git a/ui/components/issues/verdict-filter.test.tsx b/ui/components/issues/verdict-filter.test.tsx
index de3e7cb7c1..6d7dea725b 100644
--- a/ui/components/issues/verdict-filter.test.tsx
+++ b/ui/components/issues/verdict-filter.test.tsx
@@ -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();
+ render();
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();
+ render();
expect(
screen.getByRole("tab", { name: "Likely, 0 issues" }),
@@ -72,7 +69,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"filter[verdict__in]=exploitable,likely",
);
- render();
+ render();
// 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();
+ render();
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();
+ render();
// When
await user.click(
screen.getByRole("button", {
diff --git a/ui/components/issues/verdict-filter.tsx b/ui/components/issues/verdict-filter.tsx
index f9184967ca..15bb742459 100644
--- a/ui/components/issues/verdict-filter.tsx
+++ b/ui/components/issues/verdict-filter.tsx
@@ -15,8 +15,6 @@ interface VerdictSummary {
counts: Record;
/** 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 }) {
))}
- {summary ? (
- // The fold: how much noise the issue count removed.
-
- {summary.findings.toLocaleString()} failed findings folded into{" "}
- {summary.total.toLocaleString()}{" "}
- {summary.total === 1 ? "issue" : "issues"}
-
- ) : null}
{/* Only filters that arrived by link get a chip; the tabs and the scope