Compare commits

...

6 Commits

Author SHA1 Message Date
Pablo F.G 42f91b3747 docs(ui): link attack-paths changelog entry to PR #11512
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:31:47 +02:00
Pablo F.G d98460ae93 docs(ui): changelog for adaptive attack-paths waiting message
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:06:50 +02:00
Pablo F.G eb8b8c1f44 fix(ui): show adaptive Attack Paths message for running, building and error states
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:56:09 +02:00
Pablo F.G c0a2a8f7c6 test(ui): add attack-paths fixtures for running/building/no-graph states
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:48:40 +02:00
Pablo F.G 9cceee7a9f feat(ui): add attack-paths status panel for waiting states
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:47:02 +02:00
Pablo F.G 7ee0a7c755 feat(ui): derive attack-paths page view-state from scan status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:45:20 +02:00
8 changed files with 475 additions and 31 deletions
+4
View File
@@ -8,6 +8,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
### 🐞 Fixed
- Attack Paths now shows distinct messages while a scan is running or its graph is being built, plus a separate "couldn't load scans" error, instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512)
---
## [1.29.2] (Prowler v5.29.2)
@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
describe("AttackPathsStatusPanel", () => {
it("renders the no-scans message with a link to Scan Jobs", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.NO_SCANS} />,
);
expect(screen.getByText(/no scans available/i)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /go to scan jobs/i }),
).toHaveAttribute("href", "/scans");
});
it("renders the scan-running message", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING} />,
);
expect(screen.getByText(/scan in progress/i)).toBeInTheDocument();
});
it("renders the graph-building message with progress", () => {
render(
<AttackPathsStatusPanel
state={ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING}
progress={45}
/>,
);
expect(
screen.getByText(/preparing attack paths data/i),
).toBeInTheDocument();
expect(screen.getByText(/45%/)).toBeInTheDocument();
});
it("renders the no-graph-data message", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA} />,
);
expect(screen.getByText(/no attack paths data/i)).toBeInTheDocument();
});
it("renders the error message and calls onRetry when Retry is clicked", () => {
const onRetry = vi.fn();
render(
<AttackPathsStatusPanel
state={ATTACK_PATHS_VIEW_STATES.ERROR}
onRetry={onRetry}
/>,
);
expect(screen.getByText(/couldn.t load scans/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("renders nothing for the ready state", () => {
const { container } = render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.READY} />,
);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,109 @@
import { CircleAlert, Info } from "lucide-react";
import Link from "next/link";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
} from "@/components/shadcn";
import {
ATTACK_PATHS_VIEW_STATES,
type AttackPathsViewState,
} from "../_lib/get-attack-paths-view-state";
interface AttackPathsStatusPanelProps {
state: AttackPathsViewState;
progress?: number;
onRetry?: () => void;
}
/**
* Full-page status message shown whenever the Attack Paths graph is not yet
* queryable. The page renders the normal workflow instead once `state` is
* `READY` (this component renders nothing for `READY`/`LOADING`).
*/
export const AttackPathsStatusPanel = ({
state,
progress = 0,
onRetry,
}: AttackPathsStatusPanelProps) => {
if (state === ATTACK_PATHS_VIEW_STATES.ERROR) {
return (
<Alert variant="error">
<CircleAlert className="size-4" />
<AlertTitle>Couldn&apos;t load scans</AlertTitle>
<AlertDescription className="flex flex-col items-start gap-3">
<span>Something went wrong loading your scans.</span>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
) : null}
</AlertDescription>
</Alert>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.NO_SCANS) {
return (
<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>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>Scan in progress</AlertTitle>
<AlertDescription>
<span>
Your scan is running. Attack Paths will be available once it
completes.
</span>
</AlertDescription>
</Alert>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>Preparing Attack Paths data</AlertTitle>
<AlertDescription>
<span>
We&apos;re building the graph from your latest scan ({progress}%).
This will be ready shortly.
</span>
</AlertDescription>
</Alert>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>No Attack Paths data</AlertTitle>
<AlertDescription>
<span>Your scan completed but didn&apos;t produce graph data.</span>
</AlertDescription>
</Alert>
);
}
return null;
};
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
getGraphBuildingProgress,
} from "./get-attack-paths-view-state";
const scan = (
state: ScanState,
graph_data_ready: boolean,
progress = 0,
): AttackPathScan => ({
type: "attack-paths-scans",
id: `${state}-${String(graph_data_ready)}-${progress}`,
attributes: {
state,
progress,
graph_data_ready,
provider_alias: "Provider",
provider_type: "aws",
provider_uid: "123456789012",
inserted_at: "2026-04-21T10:00:00Z",
started_at: "2026-04-21T10:00:00Z",
completed_at: null,
duration: null,
},
relationships: {
provider: { data: { type: "providers", id: "p" } },
scan: { data: { type: "scans", id: "s" } },
task: { data: { type: "tasks", id: "t" } },
},
});
describe("getAttackPathsViewState", () => {
it("returns loading while scans are loading, regardless of other inputs", () => {
expect(
getAttackPathsViewState({
scansLoading: true,
loadError: true,
scans: [],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.LOADING);
});
it("returns error on load failure (error wins over empty scans)", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: true,
scans: [],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.ERROR);
});
it("returns no-scans for an empty list", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.NO_SCANS);
});
it("returns ready when any provider has a queryable graph", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("executing", false, 50), scan("completed", true, 100)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.READY);
});
it("returns graph-building when none ready and some scan is executing (wins over scheduled)", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("scheduled", false), scan("executing", false, 30)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING);
});
it("returns scan-running when none ready and some scan is scheduled/available", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("scheduled", false)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING);
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("available", false)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING);
});
it("returns no-graph-data when none ready and all scans are terminal", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("completed", false), scan("failed", false)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA);
});
});
describe("getGraphBuildingProgress", () => {
it("returns the max progress among executing scans", () => {
expect(
getGraphBuildingProgress([
scan("executing", false, 30),
scan("executing", false, 70),
scan("scheduled", false, 99),
]),
).toBe(70);
});
it("returns 0 when no scan is executing", () => {
expect(getGraphBuildingProgress([scan("scheduled", false, 50)])).toBe(0);
});
});
@@ -0,0 +1,59 @@
import type { AttackPathScan } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
export const ATTACK_PATHS_VIEW_STATES = {
LOADING: "loading",
ERROR: "error",
NO_SCANS: "no-scans",
SCAN_RUNNING: "scan-running",
GRAPH_BUILDING: "graph-building",
NO_GRAPH_DATA: "no-graph-data",
READY: "ready",
} as const;
export type AttackPathsViewState =
(typeof ATTACK_PATHS_VIEW_STATES)[keyof typeof ATTACK_PATHS_VIEW_STATES];
interface GetAttackPathsViewStateInput {
scansLoading: boolean;
loadError: boolean;
scans: AttackPathScan[];
}
/**
* Single source of truth for what the Attack Paths page shows. The full-page
* message owns every "not queryable yet" state; the workflow renders only once
* at least one provider's graph is ready.
*/
export const getAttackPathsViewState = ({
scansLoading,
loadError,
scans,
}: GetAttackPathsViewStateInput): AttackPathsViewState => {
if (scansLoading) return ATTACK_PATHS_VIEW_STATES.LOADING;
if (loadError) return ATTACK_PATHS_VIEW_STATES.ERROR;
if (scans.length === 0) return ATTACK_PATHS_VIEW_STATES.NO_SCANS;
if (scans.some((s) => s.attributes.graph_data_ready)) {
return ATTACK_PATHS_VIEW_STATES.READY;
}
if (scans.some((s) => s.attributes.state === SCAN_STATES.EXECUTING)) {
return ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING;
}
if (
scans.some(
(s) =>
s.attributes.state === SCAN_STATES.SCHEDULED ||
s.attributes.state === SCAN_STATES.AVAILABLE,
)
) {
return ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING;
}
return ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA;
};
/** Highest progress among scans whose graph is actively building. */
export const getGraphBuildingProgress = (scans: AttackPathScan[]): number =>
scans
.filter((s) => s.attributes.state === SCAN_STATES.EXECUTING)
.reduce((max, s) => Math.max(max, s.attributes.progress), 0);
@@ -75,6 +75,31 @@ describe("loading the page", () => {
});
});
describe("waiting states", () => {
test("a running scan shows the scan-in-progress message", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.scanRunning());
expect(await graph.emptyStateMessage()).toMatch(/scan in progress/i);
});
test("a building graph shows the preparing message with progress", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.graphBuilding());
const message = await graph.emptyStateMessage();
expect(message).toMatch(/preparing attack paths data/i);
expect(message).toMatch(/45%/);
});
test("a completed scan with no graph shows the no-data message", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.noGraphData());
expect(await graph.emptyStateMessage()).toMatch(/no attack paths data/i);
});
});
describe("running a query", () => {
test("the graph renders with a background, a minimap, and a viewport", async ({
mountWith,
@@ -143,6 +143,52 @@ export const emptyScans = (): PageFixture => ({
queryResult: null,
});
export const scanRunning = (): PageFixture => ({
scans: [
buildScan(TYPICAL_SCAN_ID, {
state: "scheduled",
progress: 0,
graph_data_ready: false,
completed_at: null,
duration: null,
}),
],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
export const graphBuilding = (): PageFixture => ({
scans: [
buildScan(TYPICAL_SCAN_ID, {
state: "executing",
progress: 45,
graph_data_ready: false,
completed_at: null,
duration: null,
}),
],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
export const noGraphData = (): PageFixture => ({
scans: [
buildScan(TYPICAL_SCAN_ID, {
state: "completed",
progress: 100,
graph_data_ready: false,
}),
],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
export const emptyGraph = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
@@ -269,6 +315,9 @@ export const edgeCases = (): PageFixture => {
export const fixtures = {
typical,
emptyScans,
scanRunning,
graphBuilding,
noGraphData,
emptyGraph,
singleNode,
findingsOnly,
@@ -1,7 +1,6 @@
"use client";
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
@@ -52,10 +51,16 @@ import {
QuerySelector,
ScanListTable,
} from "./_components";
import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel";
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsPNG } from "./_lib";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
getGraphBuildingProgress,
} from "./_lib/get-attack-paths-view-state";
/**
* Attack Paths
@@ -70,6 +75,7 @@ export default function AttackPathsPage() {
const [scansLoading, setScansLoading] = useState(true);
const [scans, setScans] = useState<AttackPathScan[]>([]);
const [loadError, setLoadError] = useState(false);
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
@@ -97,25 +103,28 @@ export default function AttackPathsPage() {
graphState.resetGraph();
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
// Load available scans on mount
useEffect(() => {
const loadScans = async () => {
setScansLoading(true);
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
} else {
setScans([]);
}
} catch (error) {
console.error("Failed to load scans:", error);
// Load available scans; reused by the error-state Retry action.
const loadScans = async () => {
setScansLoading(true);
setLoadError(false);
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
} else {
setScans([]);
} finally {
setScansLoading(false);
setLoadError(true);
}
};
} catch (error) {
console.error("Failed to load scans:", error);
setScans([]);
setLoadError(true);
} finally {
setScansLoading(false);
}
};
useEffect(() => {
loadScans();
}, []);
@@ -126,6 +135,8 @@ export default function AttackPathsPage() {
scan.attributes.state === SCAN_STATES.SCHEDULED,
);
const viewState = getAttackPathsViewState({ scansLoading, loadError, scans });
// Detect if the selected scan is showing data from a previous cycle
const selectedScan = scans.find((scan) => scan.id === scanId);
const isViewingPreviousCycleData =
@@ -386,23 +397,16 @@ export default function AttackPathsPage() {
</p>
</div>
{scansLoading ? (
{viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? (
<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 ? (
<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>
) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? (
<AttackPathsStatusPanel
state={viewState}
progress={getGraphBuildingProgress(scans)}
onRetry={loadScans}
/>
) : (
<>
{/* Scans Table */}