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:
Pablo Fernandez Guerra (PFE)
2026-06-18 10:03:35 +02:00
committed by GitHub
parent 5a761f341b
commit 2293cab72c
12 changed files with 636 additions and 83 deletions
+1
View File
@@ -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)
@@ -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();
});
});
@@ -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&apos;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&apos;t produce Attack Paths data.</span>
</StatusAlert>
);
}
return null;
};
@@ -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>
);
};
@@ -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,
};
}
@@ -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);
});
});
@@ -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);
@@ -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");
});
});
+41
View File
@@ -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>
);
};