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 && ( + + )} +
    {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