diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 0737ece790..dba8f17aab 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -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
diff --git a/ui/actions/attack-paths/queries.test.ts b/ui/actions/attack-paths/queries.test.ts
new file mode 100644
index 0000000000..6c2be5f15d
--- /dev/null
+++ b/ui/actions/attack-paths/queries.test.ts
@@ -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();
+ });
+});
diff --git a/ui/actions/attack-paths/queries.ts b/ui/actions/attack-paths/queries.ts
index 0332e6ec22..bc9068d52e 100644
--- a/ui/actions/attack-paths/queries.ts
+++ b/ui/actions/attack-paths/queries.ts
@@ -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,
-): Promise => {
+): Promise => {
// 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,
+ };
}
};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx
index 5cf3749265..09d208f125 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx
@@ -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) => {
Status
Progress
Duration
- Action
+
@@ -344,10 +348,6 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
)}
-
- Scans can be selected when data is available. A new scan does not
- interrupt access to existing data.
-
>
);
};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx
index de8833749e..71c8488e00 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx
@@ -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,
},
};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx
index f02b1205ab..22f931d6d6 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx
@@ -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,355 +335,377 @@ export default function AttackPathAnalysisPage() {
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
+
+ Scans can be selected when data is available. A new scan does not
+ interrupt access to existing data.
+
- {/* Top Section - Scans Table and Query Builder (2 columns) */}
-
- {/* Scans Table Section - Left Column */}
-
- {scansLoading ? (
-
- ) : scans.length === 0 ? (
-
- ) : (
-
Loading scans... }>
-
-
- )}
-
-
- {/* Query Builder Section - Right Column */}
+ {scansLoading ? (
- {!scanId ? (
-
- Select a scan from the table on the left to begin.
-
- ) : queriesLoading ? (
-
Loading queries...
- ) : queriesError ? (
-
- {queriesError}
-
- ) : (
- <>
-
-
+ Loading scans...
+
+ ) : scans.length === 0 ? (
+
+
+ No scans available
+
+
+ You need to run a scan before you can analyze attack paths.{" "}
+
+ Go to Scan Jobs
+
+
+
+
+ ) : (
+ <>
+ {/* Scans Table */}
+ Loading scans...}>
+
+
- {queryBuilder.selectedQueryData && (
-
-
- {/* Bottom Section - Graph Visualization (Full Width) */}
-
- {graphState.loading ? (
-
- ) : graphState.data &&
- graphState.data.nodes &&
- graphState.data.nodes.length > 0 ? (
- <>
- {/* Info message and controls */}
-
- {graphState.isFilteredView ? (
-
-
-
- Back to Full View
-
-
-
- 🔍
-
-
- Showing paths for:{" "}
-
- {graphState.filteredNode?.properties?.name ||
- graphState.filteredNode?.properties?.id ||
- "Selected node"}
-
-
-
-
- ) : (
-
-
- 💡
-
-
- Click on any node to filter and view its connected paths
-
-
- )}
-
- {/* Graph controls and fullscreen button together */}
-
-
graphRef.current?.zoomIn()}
- onZoomOut={() => graphRef.current?.zoomOut()}
- onFitToScreen={() => graphRef.current?.resetZoom()}
- onExport={() =>
- handleGraphExport(graphRef.current?.getSVGElement() || null)
- }
- />
-
- {/* Fullscreen button */}
-
-
-
-
-
-
-
-
-
-
- Graph Fullscreen View
-
-
-
- fullscreenGraphRef.current?.zoomIn()}
- onZoomOut={() =>
- fullscreenGraphRef.current?.zoomOut()
- }
- onFitToScreen={() =>
- fullscreenGraphRef.current?.resetZoom()
- }
- onExport={() =>
- handleGraphExport(
- fullscreenGraphRef.current?.getSVGElement() ||
- null,
- )
- }
- />
-
-
-
- {/* Node Detail Panel - Side by side */}
- {graphState.selectedNode && (
-
-
-
-
-
- Node Details
-
-
-
-
-
-
- {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"}
-
-
-
-
- Type
-
-
- {graphState.selectedNode?.labels
- .map(formatNodeLabel)
- .join(", ")}
-
-
-
-
-
-
+
+ {queryBuilder.selectedQueryData.attributes
+ .attribution && (
+
+ Source:{" "}
+
+ {
+ queryBuilder.selectedQueryData.attributes
+ .attribution.text
+ }
+
+
)}
-
-
+ )}
+
+ {queryBuilder.selectedQuery && (
+
+ )}
+
+
+
+
+
+
+ {graphState.error && (
+
+ {graphState.error}
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* Graph Visualization (Full Width) */}
+ {(graphState.loading ||
+ (graphState.data &&
+ graphState.data.nodes &&
+ graphState.data.nodes.length > 0)) && (
+
+ {graphState.loading ? (
+
+ ) : graphState.data &&
+ graphState.data.nodes &&
+ graphState.data.nodes.length > 0 ? (
+ <>
+ {/* Info message and controls */}
+
+ {graphState.isFilteredView ? (
+
+
+
+ Back to Full View
+
+
+
+ 🔍
+
+
+ Showing paths for:{" "}
+
+ {graphState.filteredNode?.properties?.name ||
+ graphState.filteredNode?.properties?.id ||
+ "Selected node"}
+
+
+
+
+ ) : (
+
+
+ 💡
+
+
+ Click on any node to filter and view its connected
+ paths
+
+
+ )}
+
+ {/* Graph controls and fullscreen button together */}
+
+
graphRef.current?.zoomIn()}
+ onZoomOut={() => graphRef.current?.zoomOut()}
+ onFitToScreen={() => graphRef.current?.resetZoom()}
+ onExport={() =>
+ handleGraphExport(
+ graphRef.current?.getSVGElement() || null,
+ )
+ }
+ />
+
+ {/* Fullscreen button */}
+
+
+
+
+
+
+
+
+
+
+ Graph Fullscreen View
+
+
+
+
+ fullscreenGraphRef.current?.zoomIn()
+ }
+ onZoomOut={() =>
+ fullscreenGraphRef.current?.zoomOut()
+ }
+ onFitToScreen={() =>
+ fullscreenGraphRef.current?.resetZoom()
+ }
+ onExport={() =>
+ handleGraphExport(
+ fullscreenGraphRef.current?.getSVGElement() ||
+ null,
+ )
+ }
+ />
+
+
+
+ {/* Node Detail Panel - Side by side */}
+ {graphState.selectedNode && (
+
+
+
+
+
+ Node Details
+
+
+
+
+
+
+ {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"}
+
+
+
+
+ Type
+
+
+ {graphState.selectedNode?.labels
+ .map(formatNodeLabel)
+ .join(", ")}
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {/* Graph in the middle */}
+
+
+ {/* Legend below */}
+
+
+
+ >
+ ) : null}
+
+ )}
+
+ {/* Node Detail Panel - Below Graph */}
+ {graphState.selectedNode && graphState.data && (
+
+
+
+
Node Details
+
+ {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",
+ )}
+
+
+
+ {graphState.selectedNode.labels.some((label) =>
+ label.toLowerCase().includes("finding"),
+ ) && (
+
+
+ View Finding →
+
+
+ )}
+
+
+
-
- {/* Graph in the middle */}
-
-
- {/* Legend below */}
-
-
-
- >
- ) : (
-
-
- Select a query and click "Execute Query" to visualize
- the Attack Paths graph
-
-
- )}
-
-
- {/* Node Detail Panel - Below Graph */}
- {graphState.selectedNode && graphState.data && (
-
-
-
-
Node Details
-
- {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",
- )}
-
-
-
- {graphState.selectedNode.labels.some((label) =>
- label.toLowerCase().includes("finding"),
- ) && (
-
-
- View Finding →
-
-
- )}
-
-
-
-
-
-
-
-
+ )}
+ >
)}
);
diff --git a/ui/types/attack-paths.ts b/ui/types/attack-paths.ts
index 33a02e1178..d0f35c96f7 100644
--- a/ui/types/attack-paths.ts
+++ b/ui/types/attack-paths.ts
@@ -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",