mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 12:24:45 +00:00
feat(ui): bring the issues UI closer to the original prototype (#13154)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
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} />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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={{}} />
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user