mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-13 14:11:14 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42f91b3747 | |||
| d98460ae93 | |||
| eb8b8c1f44 | |||
| c0a2a8f7c6 | |||
| 9cceee7a9f | |||
| 7ee0a7c755 |
@@ -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)
|
||||
|
||||
+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-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();
|
||||
});
|
||||
});
|
||||
+109
@@ -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'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'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't produce graph data.</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+130
@@ -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);
|
||||
});
|
||||
});
|
||||
+59
@@ -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);
|
||||
+25
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user