Skip to content

Commit 84f379c

Browse files
refactor: remove attack paths wizard and integrate into single view
- Remove two-step wizard workflow (select-scan → query-builder) - Integrate scans table and query builder into single page with 3-section layout: * Top left: Scans table with pagination * Top right: Query builder * Bottom (full width): Attack path graph visualization - Update main attack-paths page to redirect to integrated view - Remove WorkflowAttackPaths stepper component from layout - Add pagination to scans table matching DataTablePagination pattern - Move ScanListTable and ScanStatusBadge to query-builder components - Update graph height to h-screen for better visualization - Disable scroll wheel zoom on graph while keeping zoom controls functional - Improve scan status badges with proper dark mode support 🤖 Generated with Claude Code Co-Authored-By: Claude <[email protected]>
1 parent 3aade2c commit 84f379c

File tree

12 files changed

+546
-441
lines changed

12 files changed

+546
-441
lines changed

ui/app/(prowler)/attack-paths/(workflow)/layout.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { Navbar } from "@/components/ui/nav-bar/navbar";
22

3-
import { WorkflowAttackPaths } from "./_components";
4-
53
/**
6-
* Workflow layout for Attack Paths wizard
7-
* Displays the stepper at the top and step content below using full width
4+
* Workflow layout for Attack Paths
5+
* Displays content with navbar
86
*/
97
export default function AttackPathsWorkflowLayout({
108
children,
@@ -15,12 +13,7 @@ export default function AttackPathsWorkflowLayout({
1513
<>
1614
<Navbar title="Attack Path Analysis" icon="" />
1715
<div className="px-6 py-4 sm:px-8 xl:px-10">
18-
{/* Stepper - Full Width at Top */}
19-
<div className="mb-8">
20-
<WorkflowAttackPaths />
21-
</div>
22-
23-
{/* Step Content - Full Width Below */}
16+
{/* Content */}
2417
<div>{children}</div>
2518
</div>
2619
</>

ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,9 @@ const AttackPathGraphComponent = forwardRef<
372372

373373
svg.call(zoom);
374374

375+
// Disable scroll/wheel zoom, keep only programmatic zoom from controls
376+
svg.on("wheel.zoom", null);
377+
375378
// Reset auto-center flag for new data
376379
hasAutocenteredRef.current = false;
377380

ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ export * from "./graph";
33
export * from "./node-detail";
44
export { QueryParametersForm } from "./query-parameters-form";
55
export { QuerySelector } from "./query-selector";
6+
export { ScanListTable } from "./scan-list-table";
7+
export { ScanStatusBadge } from "./scan-status-badge";
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"use client";
2+
3+
import {
4+
ChevronLeftIcon,
5+
ChevronRightIcon,
6+
DoubleArrowLeftIcon,
7+
DoubleArrowRightIcon,
8+
} from "@radix-ui/react-icons";
9+
import Link from "next/link";
10+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
11+
import { useState } from "react";
12+
13+
import { cn } from "@/lib/utils";
14+
import { Button } from "@/components/shadcn/button/button";
15+
import { DateWithTime } from "@/components/ui/entities/date-with-time";
16+
import { EntityInfoShort } from "@/components/ui/entities/entity-info-short";
17+
import {
18+
Select,
19+
SelectContent,
20+
SelectItem,
21+
SelectTrigger,
22+
SelectValue,
23+
} from "@/components/ui/select/Select";
24+
import {
25+
Table,
26+
TableBody,
27+
TableCell,
28+
TableHead,
29+
TableHeader,
30+
TableRow,
31+
} from "@/components/ui/table";
32+
import type { ProviderType } from "@/types";
33+
import type { AttackPathScan } from "@/types/attack-paths";
34+
import { SCAN_STATES } from "@/types/attack-paths";
35+
36+
import { ScanStatusBadge } from "./scan-status-badge";
37+
38+
interface ScanListTableProps {
39+
scans: AttackPathScan[];
40+
}
41+
42+
const TABLE_COLUMN_COUNT = 6;
43+
const DEFAULT_PAGE_SIZE = 5;
44+
const PAGE_SIZE_OPTIONS = [2, 5, 10, 15];
45+
46+
const baseLinkClass =
47+
"relative block rounded border-0 bg-transparent px-3 py-1.5 text-button-primary outline-none transition-all duration-300 hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary focus:shadow-none dark:hover:bg-bg-neutral-secondary dark:hover:text-text-neutral-primary";
48+
49+
const disabledLinkClass =
50+
"text-text-neutral-secondary dark:text-text-neutral-secondary hover:bg-transparent hover:text-text-neutral-secondary dark:hover:text-text-neutral-secondary cursor-default pointer-events-none";
51+
52+
/**
53+
* Table displaying AWS account attack path scans
54+
* Shows scan metadata and allows selection of completed scans
55+
*/
56+
export const ScanListTable = ({ scans }: ScanListTableProps) => {
57+
const pathname = usePathname();
58+
const searchParams = useSearchParams();
59+
const router = useRouter();
60+
61+
const currentPage = parseInt(searchParams.get("scanPage") ?? "1");
62+
const pageSize = parseInt(
63+
searchParams.get("scanPageSize") ?? String(DEFAULT_PAGE_SIZE),
64+
);
65+
const [selectedPageSize, setSelectedPageSize] = useState(String(pageSize));
66+
67+
const totalPages = Math.ceil(scans.length / pageSize);
68+
const startIndex = (currentPage - 1) * pageSize;
69+
const endIndex = startIndex + pageSize;
70+
const paginatedScans = scans.slice(startIndex, endIndex);
71+
72+
const handleSelectScan = (scanId: string) => {
73+
const params = new URLSearchParams(searchParams);
74+
params.set("scanId", scanId);
75+
router.push(`${pathname}?${params.toString()}`);
76+
};
77+
78+
const isSelectDisabled = (scan: AttackPathScan) => {
79+
return scan.attributes.state !== SCAN_STATES.COMPLETED;
80+
};
81+
82+
const getSelectButtonLabel = (scan: AttackPathScan) => {
83+
if (scan.attributes.state === SCAN_STATES.EXECUTING) {
84+
return "Waiting...";
85+
}
86+
if (scan.attributes.state === SCAN_STATES.FAILED) {
87+
return "Failed";
88+
}
89+
return "Select";
90+
};
91+
92+
const createPageUrl = (pageNumber: number | string) => {
93+
const params = new URLSearchParams(searchParams);
94+
95+
// Preserve scanId if it exists
96+
const scanId = searchParams.get("scanId");
97+
98+
if (+pageNumber > totalPages) {
99+
return `${pathname}?${params.toString()}`;
100+
}
101+
102+
params.set("scanPage", pageNumber.toString());
103+
104+
// Ensure that scanId is preserved
105+
if (scanId) params.set("scanId", scanId);
106+
107+
return `${pathname}?${params.toString()}`;
108+
};
109+
110+
const isFirstPage = currentPage === 1;
111+
const isLastPage = currentPage === totalPages;
112+
113+
return (
114+
<>
115+
<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">
116+
<Table aria-label="Attack path scans table listing provider accounts, scan dates, status, progress, and duration">
117+
<TableHeader>
118+
<TableRow>
119+
<TableHead>Provider / Account</TableHead>
120+
<TableHead>Last Scan Date</TableHead>
121+
<TableHead>Status</TableHead>
122+
<TableHead>Progress</TableHead>
123+
<TableHead>Duration</TableHead>
124+
<TableHead className="text-right">Action</TableHead>
125+
</TableRow>
126+
</TableHeader>
127+
<TableBody>
128+
{scans.length === 0 ? (
129+
<TableRow>
130+
<TableCell
131+
colSpan={TABLE_COLUMN_COUNT}
132+
className="h-24 text-center"
133+
>
134+
No attack path scans available.
135+
</TableCell>
136+
</TableRow>
137+
) : (
138+
paginatedScans.map((scan) => {
139+
const isDisabled = isSelectDisabled(scan);
140+
const duration = scan.attributes.duration
141+
? `${Math.floor(scan.attributes.duration / 60)}m ${scan.attributes.duration % 60}s`
142+
: "-";
143+
144+
return (
145+
<TableRow key={scan.id}>
146+
<TableCell className="font-medium">
147+
<EntityInfoShort
148+
cloudProvider={
149+
scan.attributes.provider_type as ProviderType
150+
}
151+
entityAlias={scan.attributes.provider_alias}
152+
entityId={scan.attributes.provider_uid}
153+
/>
154+
</TableCell>
155+
<TableCell>
156+
{scan.attributes.completed_at ? (
157+
<DateWithTime
158+
inline
159+
dateTime={scan.attributes.completed_at}
160+
/>
161+
) : (
162+
"-"
163+
)}
164+
</TableCell>
165+
<TableCell>
166+
<ScanStatusBadge
167+
status={scan.attributes.state}
168+
progress={scan.attributes.progress}
169+
/>
170+
</TableCell>
171+
<TableCell>
172+
<span className="text-sm">
173+
{scan.attributes.progress}%
174+
</span>
175+
</TableCell>
176+
<TableCell>
177+
<span className="text-sm">{duration}</span>
178+
</TableCell>
179+
<TableCell className="text-right">
180+
<Button
181+
type="button"
182+
aria-label="Select scan"
183+
disabled={isDisabled}
184+
variant={isDisabled ? "secondary" : "default"}
185+
onClick={() => handleSelectScan(scan.id)}
186+
className="w-full max-w-24"
187+
>
188+
{getSelectButtonLabel(scan)}
189+
</Button>
190+
</TableCell>
191+
</TableRow>
192+
);
193+
})
194+
)}
195+
</TableBody>
196+
</Table>
197+
198+
{/* Pagination Controls */}
199+
{scans.length > 0 && (
200+
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
201+
<div className="text-sm whitespace-nowrap">
202+
{scans.length} scans in total
203+
</div>
204+
{scans.length > DEFAULT_PAGE_SIZE && (
205+
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
206+
{/* Rows per page selector */}
207+
<div className="flex items-center gap-2">
208+
<p className="text-sm font-medium whitespace-nowrap">
209+
Rows per page
210+
</p>
211+
<Select
212+
value={selectedPageSize}
213+
onValueChange={(value) => {
214+
setSelectedPageSize(value);
215+
216+
const params = new URLSearchParams(searchParams);
217+
218+
// Preserve scanId if it exists
219+
const scanId = searchParams.get("scanId");
220+
221+
params.set("scanPageSize", value);
222+
params.set("scanPage", "1");
223+
224+
// Ensure that scanId is preserved
225+
if (scanId) params.set("scanId", scanId);
226+
227+
router.push(`${pathname}?${params.toString()}`);
228+
}}
229+
>
230+
<SelectTrigger className="h-8 w-18">
231+
<SelectValue />
232+
</SelectTrigger>
233+
<SelectContent side="top">
234+
{PAGE_SIZE_OPTIONS.map((size) => (
235+
<SelectItem
236+
key={size}
237+
value={`${size}`}
238+
className="cursor-pointer"
239+
>
240+
{size}
241+
</SelectItem>
242+
))}
243+
</SelectContent>
244+
</Select>
245+
</div>
246+
<div className="flex items-center justify-center text-sm font-medium">
247+
Page {currentPage} of {totalPages}
248+
</div>
249+
<div className="flex items-center gap-2">
250+
<Link
251+
aria-label="Go to first page"
252+
className={cn(baseLinkClass, isFirstPage && disabledLinkClass)}
253+
href={
254+
isFirstPage
255+
? pathname + "?" + searchParams.toString()
256+
: createPageUrl(1)
257+
}
258+
aria-disabled={isFirstPage}
259+
onClick={(e) => isFirstPage && e.preventDefault()}
260+
>
261+
<DoubleArrowLeftIcon
262+
className="size-4"
263+
aria-hidden="true"
264+
/>
265+
</Link>
266+
<Link
267+
aria-label="Go to previous page"
268+
className={cn(baseLinkClass, isFirstPage && disabledLinkClass)}
269+
href={
270+
isFirstPage
271+
? pathname + "?" + searchParams.toString()
272+
: createPageUrl(currentPage - 1)
273+
}
274+
aria-disabled={isFirstPage}
275+
onClick={(e) => isFirstPage && e.preventDefault()}
276+
>
277+
<ChevronLeftIcon className="size-4" aria-hidden="true" />
278+
</Link>
279+
<Link
280+
aria-label="Go to next page"
281+
className={cn(baseLinkClass, isLastPage && disabledLinkClass)}
282+
href={
283+
isLastPage
284+
? pathname + "?" + searchParams.toString()
285+
: createPageUrl(currentPage + 1)
286+
}
287+
aria-disabled={isLastPage}
288+
onClick={(e) => isLastPage && e.preventDefault()}
289+
>
290+
<ChevronRightIcon className="size-4" aria-hidden="true" />
291+
</Link>
292+
<Link
293+
aria-label="Go to last page"
294+
className={cn(baseLinkClass, isLastPage && disabledLinkClass)}
295+
href={
296+
isLastPage
297+
? pathname + "?" + searchParams.toString()
298+
: createPageUrl(totalPages)
299+
}
300+
aria-disabled={isLastPage}
301+
onClick={(e) => isLastPage && e.preventDefault()}
302+
>
303+
<DoubleArrowRightIcon
304+
className="size-4"
305+
aria-hidden="true"
306+
/>
307+
</Link>
308+
</div>
309+
</div>
310+
)}
311+
</div>
312+
)}
313+
</div>
314+
<p className="mt-6 text-xs text-text-neutral-secondary dark:text-text-neutral-secondary">
315+
Only attack path scans with &quot;Completed&quot; status can be
316+
selected. Scans in progress will update automatically.
317+
</p>
318+
</>
319+
);
320+
};

ui/app/(prowler)/attack-paths/(workflow)/select-scan/_components/scan-status-badge.tsx renamed to ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,24 @@ export const ScanStatusBadge = ({
2020
}: ScanStatusBadgeProps) => {
2121
if (status === "executing") {
2222
return (
23-
<span className="inline-flex items-center gap-2 rounded-full border border-amber-400 bg-amber-400/20 px-2 py-0.5 text-xs font-medium text-amber-700 dark:border-amber-600 dark:bg-amber-950/40 dark:text-amber-400">
23+
<Badge variant="outline" className="border-amber-600 bg-amber-50 text-amber-900 dark:border-amber-400 dark:bg-amber-950 dark:text-amber-200 gap-2">
2424
<Loader2 size={14} className="animate-spin" />
2525
<span>In Progress ({progress}%)</span>
26-
</span>
26+
</Badge>
2727
);
2828
}
2929

3030
if (status === "completed") {
3131
return (
32-
<span className="inline-flex items-center gap-2 rounded-full border border-green-400 bg-green-400/20 px-2 py-0.5 text-xs font-medium text-green-700 dark:border-green-600 dark:bg-green-950/40 dark:text-green-400">
32+
<Badge variant="outline" className="border-green-600 bg-green-50 text-green-900 dark:border-green-400 dark:bg-green-950 dark:text-green-200 gap-2">
3333
<span>Completed</span>
34-
</span>
34+
</Badge>
3535
);
3636
}
3737

3838
return (
3939
<Badge variant="destructive" className="gap-2">
40-
<span className="text-xs font-medium">Failed</span>
40+
<span>Failed</span>
4141
</Badge>
4242
);
4343
};

0 commit comments

Comments
 (0)