diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index a5b56ab23c..3de8c46d56 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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 _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx new file mode 100644 index 0000000000..20210a3126 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(screen.getByText(/scan in progress/i)).toBeInTheDocument(); + }); + + it("renders the graph-building message with progress", () => { + render( + , + ); + expect( + screen.getByText(/preparing attack paths data/i), + ).toBeInTheDocument(); + expect(screen.getByText(/45%/)).toBeInTheDocument(); + }); + + it("renders the no-graph-data message", () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx new file mode 100644 index 0000000000..89db0dbfd2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx @@ -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 ( + + Something went wrong loading your scans. + {onRetry ? ( + + ) : null} + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_SCANS) { + return ( + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.SCAN_PENDING) { + return ( + + + Your scan is queued. Attack Paths will be available once it completes. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING) { + return ( + + + We're building the graph from your latest scan ({progress}%). + This will be ready shortly. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA) { + return ( + + This scan didn't produce Attack Paths data. + + ); + } + + return null; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx index 084bcf6e16..b451cf58f6 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx @@ -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 ( - - - {title} - - {description ?

{description}

: null} -
-
-            {error}
-          
-
-
-
+ + {description ?

{description}

: null} +
+
+          {error}
+        
+
+
); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts index 39515698a1..5957e175d7 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts @@ -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; + retryLoadScans: () => Promise; } /** @@ -35,7 +37,11 @@ export function useAttackPathScans( const [scans, setScans] = useState([]); 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, + }; } diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts new file mode 100644 index 0000000000..c6722021ed --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts @@ -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); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts new file mode 100644 index 0000000000..0e734304f8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts @@ -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); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index 9e455a4b6d..bc2cf36331 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -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, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts index 37e2170355..41089af527 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts @@ -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, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 07f36912e4..6bcf03d082 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -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(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( @@ -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 (
@@ -418,25 +426,23 @@ export default function AttackPathsPage() {

- {scansLoading ? ( -
+ {viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? ( +

Loading scans...

- ) : 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.
- - - No scans available - - - You need to run a scan before you can analyze attack paths.{" "} - - Go to Scan Jobs - - - - +
+ ) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? ( + ) : ( <> Loading scans...
}> @@ -444,21 +450,20 @@ export default function AttackPathsPage() { {isViewingPreviousCycleData && ( - - - 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. - - + + 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. + )} {scanId && ( -
+
{queriesLoading ? (

Loading queries...

) : queriesError ? ( @@ -516,7 +521,7 @@ export default function AttackPathsPage() { (graphState.data && graphState.data.nodes && graphState.data.nodes.length > 0)) && ( -
+
{graphState.loading ? ( ) : graphState.data && diff --git a/ui/components/shared/status-alert.test.tsx b/ui/components/shared/status-alert.test.tsx new file mode 100644 index 0000000000..c99500e57e --- /dev/null +++ b/ui/components/shared/status-alert.test.tsx @@ -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( + + Something to know. + , + ); + + expect(screen.getByText("Heads up")).toBeInTheDocument(); + expect(screen.getByText("Something to know.")).toBeInTheDocument(); + }); + + it("renders the error variant with title and children", () => { + render( + + Try again. + , + ); + + expect(screen.getByText("It broke")).toBeInTheDocument(); + expect(screen.getByText("Try again.")).toBeInTheDocument(); + }); + + it("applies descriptionClassName to the description element", () => { + render( + + Body + , + ); + + const description = screen + .getByText("Body") + .closest("[data-slot='alert-description']"); + expect(description).toHaveClass("w-full", "gap-3"); + }); +}); diff --git a/ui/components/shared/status-alert.tsx b/ui/components/shared/status-alert.tsx new file mode 100644 index 0000000000..286e7e6b9a --- /dev/null +++ b/ui/components/shared/status-alert.tsx @@ -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 ( + + + {title} + + {children} + + + ); +};