feat(ui): improve attack paths page layout and UX (#10249)

This commit is contained in:
Alejandro Bailo
2026-03-06 10:49:11 +01:00
committed by GitHub
parent 97f4cb716d
commit 48df613095
7 changed files with 473 additions and 350 deletions

View File

@@ -2,7 +2,12 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.19.1] (Prowler v5.19.1)
## [1.19.1] (Prowler v5.19.1 UNRELEASED)
### 🐞 Fixed
- Attack Paths: Improved error handling for server errors (5xx) and network failures with user-friendly messages instead of raw internal errors
### 🔐 Security

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}),
);
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
import { executeQuery } from "./queries";
describe("executeQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("returns a friendly message when API response handling throws", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(null, {
status: 500,
}),
);
handleApiResponseMock.mockRejectedValue(
new Error("Server error (500): backend database unavailable"),
);
// When
const result = await executeQuery(
"550e8400-e29b-41d4-a716-446655440000",
"aws-iam-statements-allow-all-actions",
);
// Then
expect(handleApiResponseMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
status: 503,
});
});
it("returns undefined and skips fetch for invalid scan ids", async () => {
// When
const result = await executeQuery(
"not-a-uuid",
"aws-iam-statements-allow-all-actions",
);
// Then
expect(result).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
});

View File

@@ -7,6 +7,7 @@ import { handleApiResponse } from "@/lib/server-actions-helper";
import {
AttackPathQueriesResponse,
AttackPathQuery,
AttackPathQueryError,
AttackPathQueryResult,
ExecuteQueryRequest,
} from "@/types/attack-paths";
@@ -59,7 +60,7 @@ export const executeQuery = async (
scanId: string,
queryId: string,
parameters?: Record<string, string | number | boolean>,
): Promise<AttackPathQueryResult | undefined> => {
): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
// Validate scanId is a valid UUID format to prevent request forgery
const validatedScanId = UUIDSchema.safeParse(scanId);
if (!validatedScanId.success) {
@@ -89,9 +90,15 @@ export const executeQuery = async (
},
);
return handleApiResponse(response);
return (await handleApiResponse(response)) as
| AttackPathQueryResult
| AttackPathQueryError;
} catch (error) {
console.error("Error executing query on scan:", error);
return undefined;
return {
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
status: 503,
};
}
};

View File

@@ -82,7 +82,11 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
};
const isSelectDisabled = (scan: AttackPathScan) => {
return !scan.attributes.graph_data_ready || selectedScanId === scan.id;
return (
!scan.attributes.graph_data_ready ||
scan.attributes.state === SCAN_STATES.FAILED ||
selectedScanId === scan.id
);
};
const getSelectButtonLabel = (scan: AttackPathScan) => {
@@ -133,7 +137,7 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Action</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -344,10 +348,6 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
</div>
)}
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-6 text-xs">
Scans can be selected when data is available. A new scan does not
interrupt access to existing data.
</p>
</>
);
};

View File

@@ -38,7 +38,7 @@ const BADGE_CONFIG: Record<
[SCAN_STATES.FAILED]: {
className: "bg-bg-fail-secondary text-text-error-primary",
label: "Failed",
showGraphDot: true,
showGraphDot: false,
},
};

View File

@@ -1,6 +1,7 @@
"use client";
import { ArrowLeft, Maximize2, X } from "lucide-react";
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
@@ -12,7 +13,14 @@ import {
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { AutoRefresh } from "@/components/scans";
import { Button, Card, CardContent } from "@/components/shadcn";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
} from "@/components/shadcn";
import {
Dialog,
DialogContent,
@@ -23,6 +31,7 @@ import {
} from "@/components/ui";
import type {
AttackPathQuery,
AttackPathQueryError,
AttackPathScan,
GraphNode,
} from "@/types/attack-paths";
@@ -201,7 +210,7 @@ export default function AttackPathAnalysisPage() {
);
if (result && "error" in result) {
const apiError = result as unknown as { error: string; status: number };
const apiError = result as AttackPathQueryError;
graphState.resetGraph();
if (apiError.status === 404) {
@@ -213,6 +222,11 @@ export default function AttackPathAnalysisPage() {
"Error",
"Not enough permissions to execute this query",
);
} else if (apiError.status >= 500) {
const serverDownMessage =
"Server is temporarily unavailable. Please try again in a few minutes.";
graphState.setError(serverDownMessage);
showErrorToast("Error", serverDownMessage);
} else {
graphState.setError(apiError.error);
showErrorToast("Error", apiError.error);
@@ -242,8 +256,11 @@ export default function AttackPathAnalysisPage() {
);
}
} catch (error) {
const errorMsg =
const rawErrorMsg =
error instanceof Error ? error.message : "Failed to execute query";
const errorMsg = rawErrorMsg.includes("Server Components render")
? "Server is temporarily unavailable. Please try again in a few minutes."
: rawErrorMsg;
graphState.resetGraph();
graphState.setError(errorMsg);
showErrorToast("Error", errorMsg);
@@ -318,34 +335,40 @@ export default function AttackPathAnalysisPage() {
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
</p>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-xs">
Scans can be selected when data is available. A new scan does not
interrupt access to existing data.
</p>
</div>
{/* Top Section - Scans Table and Query Builder (2 columns) */}
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
{/* Scans Table Section - Left Column */}
<div>
{scansLoading ? (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
<p className="text-sm">Loading scans...</p>
</div>
) : scans.length === 0 ? (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
<p className="text-sm">No scans available</p>
</div>
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>No scans available</AlertTitle>
<AlertDescription>
<span>
You need to run a scan before you can analyze attack paths.{" "}
<Link href="/scans" className="font-medium underline">
Go to Scan Jobs
</Link>
</span>
</AlertDescription>
</Alert>
) : (
<>
{/* Scans Table */}
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
)}
</div>
{/* Query Builder Section - Right Column */}
{/* Query Builder Section - shown only after selecting a scan */}
{scanId && (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
{!scanId ? (
<p className="text-text-info dark:text-text-info text-sm">
Select a scan from the table on the left to begin.
</p>
) : queriesLoading ? (
{queriesLoading ? (
<p className="text-sm">Loading queries...</p>
) : queriesError ? (
<p className="text-text-danger dark:text-text-danger text-sm">
@@ -363,9 +386,13 @@ export default function AttackPathAnalysisPage() {
{queryBuilder.selectedQueryData && (
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md p-3 text-sm">
<p className="whitespace-pre-line">
{queryBuilder.selectedQueryData.attributes.description}
{
queryBuilder.selectedQueryData.attributes
.description
}
</p>
{queryBuilder.selectedQueryData.attributes.attribution && (
{queryBuilder.selectedQueryData.attributes
.attribution && (
<p className="mt-2 text-xs">
Source:{" "}
<a
@@ -410,9 +437,13 @@ export default function AttackPathAnalysisPage() {
</>
)}
</div>
</div>
)}
{/* Bottom Section - Graph Visualization (Full Width) */}
{/* Graph Visualization (Full Width) */}
{(graphState.loading ||
(graphState.data &&
graphState.data.nodes &&
graphState.data.nodes.length > 0)) && (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
{graphState.loading ? (
<GraphLoading />
@@ -454,7 +485,7 @@ export default function AttackPathAnalysisPage() {
</div>
) : (
<div
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
@@ -462,7 +493,8 @@ export default function AttackPathAnalysisPage() {
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected paths
Click on any node to filter and view its connected
paths
</span>
</div>
)}
@@ -474,7 +506,9 @@ export default function AttackPathAnalysisPage() {
onZoomOut={() => graphRef.current?.zoomOut()}
onFitToScreen={() => graphRef.current?.resetZoom()}
onExport={() =>
handleGraphExport(graphRef.current?.getSVGElement() || null)
handleGraphExport(
graphRef.current?.getSVGElement() || null,
)
}
/>
@@ -502,7 +536,9 @@ export default function AttackPathAnalysisPage() {
</DialogHeader>
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
<GraphControls
onZoomIn={() => fullscreenGraphRef.current?.zoomIn()}
onZoomIn={() =>
fullscreenGraphRef.current?.zoomIn()
}
onZoomOut={() =>
fullscreenGraphRef.current?.zoomOut()
}
@@ -552,15 +588,19 @@ export default function AttackPathAnalysisPage() {
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label.toLowerCase().includes("finding"),
label
.toLowerCase()
.includes("finding"),
)
? graphState.selectedNode?.properties
?.check_title ||
graphState.selectedNode?.properties?.id ||
graphState.selectedNode?.properties
?.id ||
"Unknown Finding"
: graphState.selectedNode?.properties
?.name ||
graphState.selectedNode?.properties?.id ||
graphState.selectedNode?.properties
?.id ||
"Unknown Resource"}
</p>
<div className="flex flex-col gap-4">
@@ -587,7 +627,10 @@ export default function AttackPathAnalysisPage() {
</div>
{/* Graph in the middle */}
<div ref={graphContainerRef} className="h-[calc(100vh-22rem)]">
<div
ref={graphContainerRef}
className="h-[calc(100vh-22rem)]"
>
<AttackPathGraph
ref={graphRef}
data={graphState.data}
@@ -602,15 +645,9 @@ export default function AttackPathAnalysisPage() {
<GraphLegend data={graphState.data} />
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center text-center">
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-sm">
Select a query and click &quot;Execute Query&quot; to visualize
the Attack Paths graph
</p>
) : null}
</div>
)}
</div>
{/* Node Detail Panel - Below Graph */}
{graphState.selectedNode && graphState.data && (
@@ -668,6 +705,8 @@ export default function AttackPathAnalysisPage() {
/>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -175,6 +175,11 @@ export interface AttackPathQueryResult {
data: QueryResultData;
}
export interface AttackPathQueryError {
error: string;
status: number;
}
// Finding severity and status constants
export const FINDING_SEVERITIES = {
CRITICAL: "critical",