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. 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 ### 🔐 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 { import {
AttackPathQueriesResponse, AttackPathQueriesResponse,
AttackPathQuery, AttackPathQuery,
AttackPathQueryError,
AttackPathQueryResult, AttackPathQueryResult,
ExecuteQueryRequest, ExecuteQueryRequest,
} from "@/types/attack-paths"; } from "@/types/attack-paths";
@@ -59,7 +60,7 @@ export const executeQuery = async (
scanId: string, scanId: string,
queryId: string, queryId: string,
parameters?: Record<string, string | number | boolean>, parameters?: Record<string, string | number | boolean>,
): Promise<AttackPathQueryResult | undefined> => { ): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
// Validate scanId is a valid UUID format to prevent request forgery // Validate scanId is a valid UUID format to prevent request forgery
const validatedScanId = UUIDSchema.safeParse(scanId); const validatedScanId = UUIDSchema.safeParse(scanId);
if (!validatedScanId.success) { if (!validatedScanId.success) {
@@ -89,9 +90,15 @@ export const executeQuery = async (
}, },
); );
return handleApiResponse(response); return (await handleApiResponse(response)) as
| AttackPathQueryResult
| AttackPathQueryError;
} catch (error) { } catch (error) {
console.error("Error executing query on scan:", 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) => { 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) => { const getSelectButtonLabel = (scan: AttackPathScan) => {
@@ -133,7 +137,7 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Progress</TableHead> <TableHead>Progress</TableHead>
<TableHead>Duration</TableHead> <TableHead>Duration</TableHead>
<TableHead className="text-right">Action</TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -344,10 +348,6 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
</div> </div>
)} )}
</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]: { [SCAN_STATES.FAILED]: {
className: "bg-bg-fail-secondary text-text-error-primary", className: "bg-bg-fail-secondary text-text-error-primary",
label: "Failed", label: "Failed",
showGraphDot: true, showGraphDot: false,
}, },
}; };

View File

@@ -1,6 +1,7 @@
"use client"; "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 { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
@@ -12,7 +13,14 @@ import {
} from "@/actions/attack-paths"; } from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { AutoRefresh } from "@/components/scans"; import { AutoRefresh } from "@/components/scans";
import { Button, Card, CardContent } from "@/components/shadcn"; import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
} from "@/components/shadcn";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -23,6 +31,7 @@ import {
} from "@/components/ui"; } from "@/components/ui";
import type { import type {
AttackPathQuery, AttackPathQuery,
AttackPathQueryError,
AttackPathScan, AttackPathScan,
GraphNode, GraphNode,
} from "@/types/attack-paths"; } from "@/types/attack-paths";
@@ -201,7 +210,7 @@ export default function AttackPathAnalysisPage() {
); );
if (result && "error" in result) { if (result && "error" in result) {
const apiError = result as unknown as { error: string; status: number }; const apiError = result as AttackPathQueryError;
graphState.resetGraph(); graphState.resetGraph();
if (apiError.status === 404) { if (apiError.status === 404) {
@@ -213,6 +222,11 @@ export default function AttackPathAnalysisPage() {
"Error", "Error",
"Not enough permissions to execute this query", "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 { } else {
graphState.setError(apiError.error); graphState.setError(apiError.error);
showErrorToast("Error", apiError.error); showErrorToast("Error", apiError.error);
@@ -242,8 +256,11 @@ export default function AttackPathAnalysisPage() {
); );
} }
} catch (error) { } catch (error) {
const errorMsg = const rawErrorMsg =
error instanceof Error ? error.message : "Failed to execute query"; 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.resetGraph();
graphState.setError(errorMsg); graphState.setError(errorMsg);
showErrorToast("Error", errorMsg); showErrorToast("Error", errorMsg);
@@ -318,355 +335,377 @@ export default function AttackPathAnalysisPage() {
Select a scan, build a query, and visualize Attack Paths in your Select a scan, build a query, and visualize Attack Paths in your
infrastructure. infrastructure.
</p> </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> </div>
{/* Top Section - Scans Table and Query Builder (2 columns) */} {scansLoading ? (
<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>
) : (
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
)}
</div>
{/* Query Builder Section - Right Column */}
<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"> <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-sm">Loading scans...</p>
<p className="text-text-info dark:text-text-info text-sm"> </div>
Select a scan from the table on the left to begin. ) : scans.length === 0 ? (
</p> <Alert variant="info">
) : queriesLoading ? ( <Info className="size-4" />
<p className="text-sm">Loading queries...</p> <AlertTitle>No scans available</AlertTitle>
) : queriesError ? ( <AlertDescription>
<p className="text-text-danger dark:text-text-danger text-sm"> <span>
{queriesError} You need to run a scan before you can analyze attack paths.{" "}
</p> <Link href="/scans" className="font-medium underline">
) : ( Go to Scan Jobs
<> </Link>
<FormProvider {...queryBuilder.form}> </span>
<QuerySelector </AlertDescription>
queries={queries} </Alert>
selectedQueryId={queryBuilder.selectedQuery} ) : (
onQueryChange={handleQueryChange} <>
/> {/* Scans Table */}
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
{queryBuilder.selectedQueryData && ( {/* Query Builder Section - shown only after selecting a scan */}
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md p-3 text-sm"> {scanId && (
<p className="whitespace-pre-line"> <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">
{queryBuilder.selectedQueryData.attributes.description} {queriesLoading ? (
</p> <p className="text-sm">Loading queries...</p>
{queryBuilder.selectedQueryData.attributes.attribution && ( ) : queriesError ? (
<p className="mt-2 text-xs"> <p className="text-text-danger dark:text-text-danger text-sm">
Source:{" "} {queriesError}
<a </p>
href={ ) : (
queryBuilder.selectedQueryData.attributes <>
.attribution.link <FormProvider {...queryBuilder.form}>
} <QuerySelector
target="_blank" queries={queries}
rel="noopener noreferrer" selectedQueryId={queryBuilder.selectedQuery}
className="underline" onQueryChange={handleQueryChange}
> />
{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 queryBuilder.selectedQueryData.attributes
.attribution.text .description
} }
</a> </p>
</p> {queryBuilder.selectedQueryData.attributes
)} .attribution && (
</div> <p className="mt-2 text-xs">
)} Source:{" "}
<a
{queryBuilder.selectedQuery && ( href={
<QueryParametersForm queryBuilder.selectedQueryData.attributes
selectedQuery={queryBuilder.selectedQueryData} .attribution.link
/> }
)} target="_blank"
</FormProvider> rel="noopener noreferrer"
className="underline"
<div className="flex gap-3"> >
<ExecuteButton {
isLoading={graphState.loading} queryBuilder.selectedQueryData.attributes
isDisabled={!queryBuilder.selectedQuery} .attribution.text
onExecute={handleExecuteQuery} }
/> </a>
</div> </p>
{graphState.error && (
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
{graphState.error}
</div>
)}
</>
)}
</div>
</div>
{/* Bottom Section - Graph Visualization (Full Width) */}
<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 />
) : graphState.data &&
graphState.data.nodes &&
graphState.data.nodes.length > 0 ? (
<>
{/* Info message and controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{graphState.isFilteredView ? (
<div className="flex items-center gap-3">
<Button
onClick={handleBackToFullView}
variant="outline"
size="sm"
className="gap-2"
aria-label="Return to full graph view"
>
<ArrowLeft size={16} />
Back to Full View
</Button>
<div
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="Filtered view active"
>
<span className="flex-shrink-0" aria-hidden="true">
🔍
</span>
<span className="flex-1">
Showing paths for:{" "}
<strong>
{graphState.filteredNode?.properties?.name ||
graphState.filteredNode?.properties?.id ||
"Selected node"}
</strong>
</span>
</div>
</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"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected paths
</span>
</div>
)}
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">
<GraphControls
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitToScreen={() => graphRef.current?.resetZoom()}
onExport={() =>
handleGraphExport(graphRef.current?.getSVGElement() || null)
}
/>
{/* Fullscreen button */}
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
<Dialog
open={isFullscreenOpen}
onOpenChange={setIsFullscreenOpen}
>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Fullscreen"
>
<Maximize2 size={18} />
</Button>
</DialogTrigger>
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 p-0">
<DialogHeader className="px-4 pt-4 sm:px-6 sm:pt-6">
<DialogTitle className="text-lg">
Graph Fullscreen View
</DialogTitle>
</DialogHeader>
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
<GraphControls
onZoomIn={() => fullscreenGraphRef.current?.zoomIn()}
onZoomOut={() =>
fullscreenGraphRef.current?.zoomOut()
}
onFitToScreen={() =>
fullscreenGraphRef.current?.resetZoom()
}
onExport={() =>
handleGraphExport(
fullscreenGraphRef.current?.getSVGElement() ||
null,
)
}
/>
</div>
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
<div className="flex flex-1 items-center justify-center">
<AttackPathGraph
ref={fullscreenGraphRef}
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>
{/* Node Detail Panel - Side by side */}
{graphState.selectedNode && (
<section aria-labelledby="node-details-heading">
<Card className="w-96 overflow-y-auto">
<CardContent className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3
id="node-details-heading"
className="text-sm font-semibold"
>
Node Details
</h3>
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label.toLowerCase().includes("finding"),
)
? graphState.selectedNode?.properties
?.check_title ||
graphState.selectedNode?.properties?.id ||
"Unknown Finding"
: graphState.selectedNode?.properties
?.name ||
graphState.selectedNode?.properties?.id ||
"Unknown Resource"}
</p>
<div className="flex flex-col gap-4">
<div>
<h4 className="mb-2 text-xs font-semibold">
Type
</h4>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
{graphState.selectedNode?.labels
.map(formatNodeLabel)
.join(", ")}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
)} )}
</div> </div>
</DialogContent> )}
</Dialog>
{queryBuilder.selectedQuery && (
<QueryParametersForm
selectedQuery={queryBuilder.selectedQueryData}
/>
)}
</FormProvider>
<div className="flex gap-3">
<ExecuteButton
isLoading={graphState.loading}
isDisabled={!queryBuilder.selectedQuery}
onExecute={handleExecuteQuery}
/>
</div>
{graphState.error && (
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
{graphState.error}
</div>
)}
</>
)}
</div>
)}
{/* 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 />
) : graphState.data &&
graphState.data.nodes &&
graphState.data.nodes.length > 0 ? (
<>
{/* Info message and controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{graphState.isFilteredView ? (
<div className="flex items-center gap-3">
<Button
onClick={handleBackToFullView}
variant="outline"
size="sm"
className="gap-2"
aria-label="Return to full graph view"
>
<ArrowLeft size={16} />
Back to Full View
</Button>
<div
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="Filtered view active"
>
<span className="flex-shrink-0" aria-hidden="true">
🔍
</span>
<span className="flex-1">
Showing paths for:{" "}
<strong>
{graphState.filteredNode?.properties?.name ||
graphState.filteredNode?.properties?.id ||
"Selected node"}
</strong>
</span>
</div>
</div>
) : (
<div
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"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected
paths
</span>
</div>
)}
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">
<GraphControls
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitToScreen={() => graphRef.current?.resetZoom()}
onExport={() =>
handleGraphExport(
graphRef.current?.getSVGElement() || null,
)
}
/>
{/* Fullscreen button */}
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
<Dialog
open={isFullscreenOpen}
onOpenChange={setIsFullscreenOpen}
>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Fullscreen"
>
<Maximize2 size={18} />
</Button>
</DialogTrigger>
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 p-0">
<DialogHeader className="px-4 pt-4 sm:px-6 sm:pt-6">
<DialogTitle className="text-lg">
Graph Fullscreen View
</DialogTitle>
</DialogHeader>
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
<GraphControls
onZoomIn={() =>
fullscreenGraphRef.current?.zoomIn()
}
onZoomOut={() =>
fullscreenGraphRef.current?.zoomOut()
}
onFitToScreen={() =>
fullscreenGraphRef.current?.resetZoom()
}
onExport={() =>
handleGraphExport(
fullscreenGraphRef.current?.getSVGElement() ||
null,
)
}
/>
</div>
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
<div className="flex flex-1 items-center justify-center">
<AttackPathGraph
ref={fullscreenGraphRef}
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>
{/* Node Detail Panel - Side by side */}
{graphState.selectedNode && (
<section aria-labelledby="node-details-heading">
<Card className="w-96 overflow-y-auto">
<CardContent className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3
id="node-details-heading"
className="text-sm font-semibold"
>
Node Details
</h3>
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label
.toLowerCase()
.includes("finding"),
)
? graphState.selectedNode?.properties
?.check_title ||
graphState.selectedNode?.properties
?.id ||
"Unknown Finding"
: graphState.selectedNode?.properties
?.name ||
graphState.selectedNode?.properties
?.id ||
"Unknown Resource"}
</p>
<div className="flex flex-col gap-4">
<div>
<h4 className="mb-2 text-xs font-semibold">
Type
</h4>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
{graphState.selectedNode?.labels
.map(formatNodeLabel)
.join(", ")}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
{/* Graph in the middle */}
<div
ref={graphContainerRef}
className="h-[calc(100vh-22rem)]"
>
<AttackPathGraph
ref={graphRef}
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>
{/* Legend below */}
<div className="hidden justify-center lg:flex">
<GraphLegend data={graphState.data} />
</div>
</>
) : null}
</div>
)}
{/* Node Detail Panel - Below Graph */}
{graphState.selectedNode && graphState.data && (
<div
ref={nodeDetailsRef}
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"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
{String(
graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
)
? graphState.selectedNode.properties?.check_title ||
graphState.selectedNode.properties?.id ||
"Unknown Finding"
: graphState.selectedNode.properties?.name ||
graphState.selectedNode.properties?.id ||
"Unknown Resource",
)}
</p>
</div>
<div className="flex items-center gap-2">
{graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
) && (
<Button asChild variant="default" size="sm">
<a
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
>
View Finding
</a>
</Button>
)}
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div> </div>
</div> </div>
</div>
{/* Graph in the middle */} <NodeDetailContent
<div ref={graphContainerRef} className="h-[calc(100vh-22rem)]"> node={graphState.selectedNode}
<AttackPathGraph allNodes={graphState.data.nodes}
ref={graphRef}
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/> />
</div> </div>
)}
{/* Legend below */} </>
<div className="hidden justify-center lg:flex">
<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>
</div>
)}
</div>
{/* Node Detail Panel - Below Graph */}
{graphState.selectedNode && graphState.data && (
<div
ref={nodeDetailsRef}
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"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
{String(
graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
)
? graphState.selectedNode.properties?.check_title ||
graphState.selectedNode.properties?.id ||
"Unknown Finding"
: graphState.selectedNode.properties?.name ||
graphState.selectedNode.properties?.id ||
"Unknown Resource",
)}
</p>
</div>
<div className="flex items-center gap-2">
{graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
) && (
<Button asChild variant="default" size="sm">
<a
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
>
View Finding
</a>
</Button>
)}
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
</div>
<NodeDetailContent
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
/>
</div>
)} )}
</div> </div>
); );

View File

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