mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): adaptive Attack Paths messages for waiting states (#11512)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
5a761f341b
commit
2293cab72c
@@ -10,6 +10,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512)
|
||||
- Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608)
|
||||
- Handle rename DORA to DORA_2022_2554 to follow the naming <name>_<version> in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551)
|
||||
|
||||
|
||||
+64
@@ -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-pending message", () => {
|
||||
render(
|
||||
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.SCAN_PENDING} />,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { StatusAlert } from "@/components/shared/status-alert";
|
||||
|
||||
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 (
|
||||
<StatusAlert
|
||||
variant="error"
|
||||
title="Couldn't load scans"
|
||||
descriptionClassName="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}
|
||||
</StatusAlert>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === ATTACK_PATHS_VIEW_STATES.NO_SCANS) {
|
||||
return (
|
||||
<StatusAlert variant="info" title="No scans available">
|
||||
<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>
|
||||
</StatusAlert>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === ATTACK_PATHS_VIEW_STATES.SCAN_PENDING) {
|
||||
return (
|
||||
<StatusAlert variant="info" title="Scan in progress">
|
||||
<span>
|
||||
Your scan is queued. Attack Paths will be available once it completes.
|
||||
</span>
|
||||
</StatusAlert>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING) {
|
||||
return (
|
||||
<StatusAlert variant="info" title="Preparing Attack Paths data">
|
||||
<span>
|
||||
We're building the graph from your latest scan ({progress}%).
|
||||
This will be ready shortly.
|
||||
</span>
|
||||
</StatusAlert>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA) {
|
||||
return (
|
||||
<StatusAlert variant="info" title="No Attack Paths data">
|
||||
<span>This scan didn't produce Attack Paths data.</span>
|
||||
</StatusAlert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+13
-15
@@ -1,6 +1,4 @@
|
||||
import { CircleAlert } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn";
|
||||
import { StatusAlert } from "@/components/shared/status-alert";
|
||||
|
||||
interface QueryExecutionErrorProps {
|
||||
error: string;
|
||||
@@ -14,17 +12,17 @@ export const QueryExecutionError = ({
|
||||
description,
|
||||
}: QueryExecutionErrorProps) => {
|
||||
return (
|
||||
<Alert variant="error">
|
||||
<CircleAlert className="size-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription className="w-full gap-3">
|
||||
{description ? <p>{description}</p> : null}
|
||||
<div className="bg-bg-neutral-primary/70 border-border-neutral-secondary w-full rounded-md border px-3 py-2">
|
||||
<pre className="text-text-error-primary font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<StatusAlert
|
||||
variant="error"
|
||||
title={title}
|
||||
descriptionClassName="w-full gap-3"
|
||||
>
|
||||
{description ? <p>{description}</p> : null}
|
||||
<div className="bg-bg-neutral-primary/70 border-border-neutral-secondary w-full rounded-md border px-3 py-2">
|
||||
<pre className="text-text-error-primary font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
</StatusAlert>
|
||||
);
|
||||
};
|
||||
|
||||
+41
-20
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { getAttackPathScans } from "@/actions/attack-paths";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
@@ -19,7 +19,9 @@ export interface UseAttackPathScansOptions {
|
||||
export interface UseAttackPathScansResult {
|
||||
scans: AttackPathScan[];
|
||||
scansLoading: boolean;
|
||||
loadError: boolean;
|
||||
refreshScans: () => Promise<void>;
|
||||
retryLoadScans: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +37,11 @@ export function useAttackPathScans(
|
||||
|
||||
const [scans, setScans] = useState<AttackPathScan[]>([]);
|
||||
const [scansLoading, setScansLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Silent background refresh for auto-refresh: never flips loading/error, so it
|
||||
// can't disrupt the visible view if it fails.
|
||||
const refreshScans = async () => {
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
@@ -47,35 +53,50 @@ export function useAttackPathScans(
|
||||
}
|
||||
};
|
||||
|
||||
useMountEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const loadScans = async () => {
|
||||
setScansLoading(true);
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
const nextScans = scansData?.data ?? [];
|
||||
if (!active) return;
|
||||
setScans(nextScans);
|
||||
if (!nextScans.some((scan) => scan.attributes.graph_data_ready)) {
|
||||
// Full (re)load: drives loading + error state. Runs on mount and is reused by
|
||||
// the error view's Retry action. A successful empty result (`{ data: [] }`) is
|
||||
// not an error; only a missing payload or a thrown request is.
|
||||
const loadScans = async () => {
|
||||
setScansLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (!mountedRef.current) return;
|
||||
if (scansData?.data) {
|
||||
setScans(scansData.data);
|
||||
if (!scansData.data.some((scan) => scan.attributes.graph_data_ready)) {
|
||||
onNoReadyScan?.();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
console.error("Failed to load scans:", error);
|
||||
} else {
|
||||
setScans([]);
|
||||
setLoadError(true);
|
||||
onNoReadyScan?.();
|
||||
} finally {
|
||||
if (active) setScansLoading(false);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
console.error("Failed to load scans:", error);
|
||||
setScans([]);
|
||||
setLoadError(true);
|
||||
onNoReadyScan?.();
|
||||
} finally {
|
||||
if (mountedRef.current) setScansLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useMountEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void loadScans();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
mountedRef.current = false;
|
||||
};
|
||||
});
|
||||
|
||||
return { scans, scansLoading, refreshScans };
|
||||
return {
|
||||
scans,
|
||||
scansLoading,
|
||||
loadError,
|
||||
refreshScans,
|
||||
retryLoadScans: loadScans,
|
||||
};
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
ATTACK_PATHS_VIEW_STATES,
|
||||
getAttackPathsViewState,
|
||||
getGraphBuildingProgress,
|
||||
isScanInFlight,
|
||||
} 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-pending 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_PENDING);
|
||||
expect(
|
||||
getAttackPathsViewState({
|
||||
scansLoading: false,
|
||||
loadError: false,
|
||||
scans: [scan("available", false)],
|
||||
}),
|
||||
).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_PENDING);
|
||||
});
|
||||
|
||||
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("isScanInFlight", () => {
|
||||
it("is true for an available scan (created, not yet scheduled)", () => {
|
||||
expect(isScanInFlight([scan("available", false)])).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for scheduled and executing scans", () => {
|
||||
expect(isScanInFlight([scan("scheduled", false)])).toBe(true);
|
||||
expect(isScanInFlight([scan("executing", false, 40)])).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when every scan is in a terminal state", () => {
|
||||
expect(
|
||||
isScanInFlight([scan("completed", true), scan("failed", false)]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for an empty list", () => {
|
||||
expect(isScanInFlight([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
|
||||
// In-flight = scan still progressing toward a graph. AVAILABLE is the default
|
||||
// state of a new scan. Shared by the deriver and polling so they can't diverge.
|
||||
const IN_FLIGHT_SCAN_STATES: ScanState[] = [
|
||||
SCAN_STATES.AVAILABLE,
|
||||
SCAN_STATES.SCHEDULED,
|
||||
SCAN_STATES.EXECUTING,
|
||||
];
|
||||
|
||||
export const isScanInFlight = (scans: AttackPathScan[]): boolean =>
|
||||
scans.some((s) => IN_FLIGHT_SCAN_STATES.includes(s.attributes.state));
|
||||
|
||||
export const ATTACK_PATHS_VIEW_STATES = {
|
||||
LOADING: "loading",
|
||||
ERROR: "error",
|
||||
NO_SCANS: "no-scans",
|
||||
SCAN_PENDING: "scan-pending",
|
||||
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;
|
||||
}
|
||||
// EXECUTING returned above; an in-flight scan here is AVAILABLE/SCHEDULED.
|
||||
if (isScanInFlight(scans)) {
|
||||
return ATTACK_PATHS_VIEW_STATES.SCAN_PENDING;
|
||||
}
|
||||
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);
|
||||
+25
@@ -75,6 +75,31 @@ describe("loading the page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("waiting states", () => {
|
||||
test("a pending scan shows the scan-in-progress message", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.scanPending());
|
||||
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 scanPending = (): 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,
|
||||
scanPending,
|
||||
graphBuilding,
|
||||
noGraphData,
|
||||
emptyGraph,
|
||||
singleNode,
|
||||
findingsOnly,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Maximize2 } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
@@ -17,12 +16,7 @@ import { FindingDetailDrawer } from "@/components/findings/table";
|
||||
import { PageReady } from "@/components/onboarding";
|
||||
import { useFindingDetails } from "@/components/resources/table/use-finding-details";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
} from "@/components/shadcn";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -31,6 +25,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/shadcn/dialog";
|
||||
import { StatusAlert } from "@/components/shared/status-alert";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
@@ -61,11 +56,21 @@ import {
|
||||
QuerySelector,
|
||||
ScanListTable,
|
||||
} from "./_components";
|
||||
import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel";
|
||||
import type { GraphHandle } from "./_components/graph/attack-path-graph";
|
||||
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
|
||||
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,
|
||||
isScanInFlight,
|
||||
} from "./_lib/get-attack-paths-view-state";
|
||||
|
||||
const SCROLL_CONTAINER_CLASS =
|
||||
"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";
|
||||
|
||||
export default function AttackPathsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -80,11 +85,12 @@ export default function AttackPathsPage() {
|
||||
const finding = useFindingDetails();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { scans, scansLoading, refreshScans } = useAttackPathScans({
|
||||
onNoReadyScan: isAttackPathsReplay
|
||||
? () => router.push("/scans?onboarding=view-first-scan")
|
||||
: undefined,
|
||||
});
|
||||
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
|
||||
useAttackPathScans({
|
||||
onNoReadyScan: isAttackPathsReplay
|
||||
? () => router.push("/scans?onboarding=view-first-scan")
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
@@ -103,7 +109,9 @@ export default function AttackPathsPage() {
|
||||
const hasNoScans = scans.length === 0;
|
||||
|
||||
useDriverTour(attackPathsEmptyTour, {
|
||||
enabled: onboardingEnabled && !scansLoading && hasNoScans,
|
||||
// Gate on !loadError: the empty-scans CTA anchor only renders in the
|
||||
// NO_SCANS view-state, not in the ERROR state (which also has scans === []).
|
||||
enabled: onboardingEnabled && !scansLoading && !loadError && hasNoScans,
|
||||
});
|
||||
|
||||
const { start: startAttackPathsTour } = useDriverTour<AttackPathsTourTarget>(
|
||||
@@ -164,12 +172,12 @@ export default function AttackPathsPage() {
|
||||
graphState.resetGraph();
|
||||
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
|
||||
|
||||
const hasExecutingScan = scans.some(
|
||||
(scan) =>
|
||||
scan.attributes.state === SCAN_STATES.EXECUTING ||
|
||||
scan.attributes.state === SCAN_STATES.SCHEDULED,
|
||||
);
|
||||
// Poll while a scan is in flight so the page auto-advances when the graph is ready.
|
||||
const hasScanInFlight = isScanInFlight(scans);
|
||||
|
||||
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 =
|
||||
selectedScan &&
|
||||
@@ -396,7 +404,7 @@ export default function AttackPathsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<AutoRefresh
|
||||
hasExecutingScan={hasExecutingScan}
|
||||
hasExecutingScan={hasScanInFlight}
|
||||
onRefresh={refreshScans}
|
||||
/>
|
||||
|
||||
@@ -418,25 +426,23 @@ export default function AttackPathsPage() {
|
||||
</p>
|
||||
</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">
|
||||
{viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? (
|
||||
<div className={SCROLL_CONTAINER_CLASS}>
|
||||
<p className="text-sm">Loading scans...</p>
|
||||
</div>
|
||||
) : hasNoScans ? (
|
||||
) : viewState === ATTACK_PATHS_VIEW_STATES.NO_SCANS ? (
|
||||
// Keep the empty-scans tour anchor: attackPathsEmptyTour targets
|
||||
// data-tour-id="attack-paths-empty-scans-cta". The panel's NO_SCANS
|
||||
// render is the same "No scans available" + Go to Scan Jobs CTA.
|
||||
<div data-tour-id="attack-paths-empty-scans-cta">
|
||||
<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>
|
||||
<AttackPathsStatusPanel state={viewState} />
|
||||
</div>
|
||||
) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? (
|
||||
<AttackPathsStatusPanel
|
||||
state={viewState}
|
||||
progress={getGraphBuildingProgress(scans)}
|
||||
onRetry={retryLoadScans}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
@@ -444,21 +450,20 @@ export default function AttackPathsPage() {
|
||||
</Suspense>
|
||||
|
||||
{isViewingPreviousCycleData && (
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertTitle>Viewing data from a previous scan</AlertTitle>
|
||||
<AlertDescription>
|
||||
This scan is currently{" "}
|
||||
{selectedScan.attributes.state === SCAN_STATES.EXECUTING
|
||||
? `running (${selectedScan.attributes.progress}%)`
|
||||
: selectedScan.attributes.state}
|
||||
. The graph data shown is from the last completed cycle.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<StatusAlert
|
||||
variant="info"
|
||||
title="Viewing data from a previous scan"
|
||||
>
|
||||
This scan is currently{" "}
|
||||
{selectedScan.attributes.state === SCAN_STATES.EXECUTING
|
||||
? `running (${selectedScan.attributes.progress}%)`
|
||||
: selectedScan.attributes.state}
|
||||
. The graph data shown is from the last completed cycle.
|
||||
</StatusAlert>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<div className={SCROLL_CONTAINER_CLASS}>
|
||||
{queriesLoading ? (
|
||||
<p className="text-sm">Loading queries...</p>
|
||||
) : queriesError ? (
|
||||
@@ -516,7 +521,7 @@ export default function AttackPathsPage() {
|
||||
(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">
|
||||
<div className={SCROLL_CONTAINER_CLASS}>
|
||||
{graphState.loading ? (
|
||||
<GraphLoading />
|
||||
) : graphState.data &&
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { StatusAlert } from "./status-alert";
|
||||
|
||||
describe("StatusAlert", () => {
|
||||
it("renders the info variant with title and children", () => {
|
||||
render(
|
||||
<StatusAlert variant="info" title="Heads up">
|
||||
<span>Something to know.</span>
|
||||
</StatusAlert>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Heads up")).toBeInTheDocument();
|
||||
expect(screen.getByText("Something to know.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error variant with title and children", () => {
|
||||
render(
|
||||
<StatusAlert variant="error" title="It broke">
|
||||
<span>Try again.</span>
|
||||
</StatusAlert>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("It broke")).toBeInTheDocument();
|
||||
expect(screen.getByText("Try again.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies descriptionClassName to the description element", () => {
|
||||
render(
|
||||
<StatusAlert
|
||||
variant="info"
|
||||
title="Styled"
|
||||
descriptionClassName="w-full gap-3"
|
||||
>
|
||||
<span>Body</span>
|
||||
</StatusAlert>,
|
||||
);
|
||||
|
||||
const description = screen
|
||||
.getByText("Body")
|
||||
.closest("[data-slot='alert-description']");
|
||||
expect(description).toHaveClass("w-full", "gap-3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CircleAlert, Info } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
|
||||
|
||||
const STATUS_ALERT_ICONS = {
|
||||
info: Info,
|
||||
error: CircleAlert,
|
||||
} as const;
|
||||
|
||||
type StatusAlertVariant = keyof typeof STATUS_ALERT_ICONS;
|
||||
|
||||
interface StatusAlertProps {
|
||||
variant: StatusAlertVariant;
|
||||
title: string;
|
||||
descriptionClassName?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared status banner: a shadcn `Alert` with a variant-driven icon, title, and
|
||||
* description. Use for full-width info/error messages (waiting states, load
|
||||
* failures, inline notices).
|
||||
*/
|
||||
export const StatusAlert = ({
|
||||
variant,
|
||||
title,
|
||||
descriptionClassName,
|
||||
children,
|
||||
}: StatusAlertProps) => {
|
||||
const Icon = STATUS_ALERT_ICONS[variant];
|
||||
return (
|
||||
<Alert variant={variant}>
|
||||
<Icon className="size-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription className={descriptionClassName}>
|
||||
{children}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user