fix(ui): improve attack paths scan table UX and fix info banner variant (#10704)

Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2026-04-15 17:33:29 +02:00
committed by GitHub
co-authored by Pepe Fagoaga alejandrobailo
parent 9a0c73256e
commit 9e31160887
6 changed files with 171 additions and 223 deletions
+1
View File
@@ -84,6 +84,7 @@ continue.json
.continuerc.json
# AI Coding Assistants - OpenCode
.opencode/
opencode.json
# AI Coding Assistants - GitHub Copilot
+1 -3
View File
@@ -12,11 +12,9 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Attack Paths scan selection: contextual button labels based on graph availability, tooltips on disabled actions, green dot indicator for selectable scans, and a warning banner when viewing data from a previous scan cycle [(#10685)](https://github.com/prowler-cloud/prowler/pull/10685)
### 🔄 Changed
- Remove legacy finding detail sheet, row-details wrapper, and resource detail panel; unify findings and resources around new side drawers [(#10692)](https://github.com/prowler-cloud/prowler/pull/10692)
- Attack Paths "View Finding" now opens the finding drawer inline over the graph instead of navigating to `/findings` in a new tab, preserving graph zoom, selection, and filter state
- Attack Paths scan table: replace action buttons with radio buttons, add dedicated Graph column, use info-colored In Progress badge, remove redundant Progress column, and fix info banner variant [(#10704)](https://github.com/prowler-cloud/prowler/pull/10704)
### 🐞 Fixed
@@ -169,14 +169,14 @@ describe("ScanListTable", () => {
expect(screen.getByText("12 Total Entries")).toBeInTheDocument();
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
await user.click(screen.getAllByRole("button", { name: "Select scan" })[0]);
await user.click(screen.getAllByRole("radio", { name: "Select scan" })[0]);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("enables the select button for a failed scan when graph data is ready", async () => {
it("enables the radio button for a failed scan when graph data is ready", async () => {
const user = userEvent.setup();
const failedScan: AttackPathScan = {
...createScan(1),
@@ -189,18 +189,18 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[failedScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeEnabled();
expect(button).toHaveTextContent("Select");
const radio = screen.getByRole("radio", { name: "Select scan" });
expect(radio).toBeEnabled();
expect(radio).toHaveAttribute("aria-checked", "false");
await user.click(button);
await user.click(radio);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("disables the select button for a failed scan when graph data is not ready", () => {
it("disables the radio button for a failed scan when graph data is not ready", () => {
const failedScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -212,12 +212,11 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[failedScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Failed");
const radio = screen.getByRole("radio", { name: "Scan not available" });
expect(radio).toBeDisabled();
});
it("shows 'Scheduled' label for a scheduled scan without graph data", () => {
it("shows a disabled radio button for a scheduled scan without graph data", () => {
const scheduledScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -232,12 +231,11 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[scheduledScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Scheduled");
const radio = screen.getByRole("radio", { name: "Scan not available" });
expect(radio).toBeDisabled();
});
it("shows 'Running...' label for an executing scan without graph data", () => {
it("shows a disabled radio button for an executing scan without graph data", () => {
const executingScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -252,12 +250,11 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[executingScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Running...");
const radio = screen.getByRole("radio", { name: "Scan not available" });
expect(radio).toBeDisabled();
});
it("enables Select for a scheduled scan when graph data is ready from a previous cycle", async () => {
it("enables the radio button for a scheduled scan when graph data is ready from a previous cycle", async () => {
const user = userEvent.setup();
const scheduledWithGraph: AttackPathScan = {
...createScan(1),
@@ -271,26 +268,26 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[scheduledWithGraph]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeEnabled();
expect(button).toHaveTextContent("Select");
const radio = screen.getByRole("radio", { name: "Select scan" });
expect(radio).toBeEnabled();
expect(radio).toHaveAttribute("aria-checked", "false");
await user.click(button);
await user.click(radio);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("shows a green dot next to the account name when graph data is ready", () => {
it("exposes an accessible label in the Graph column when graph data is ready", () => {
render(<ScanListTable scans={[createScan(1)]} />);
const dot = screen.getByLabelText("Graph data available");
expect(dot).toBeInTheDocument();
expect(dot).toHaveClass("bg-bg-pass-primary");
expect(screen.getByLabelText("Graph available")).toHaveClass(
"text-text-success-primary",
);
});
it("does not show a green dot when graph data is not ready", () => {
it("exposes an accessible label in the Graph column when graph data is not ready", () => {
const noGraphScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -301,8 +298,28 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[noGraphScan]} />);
expect(screen.getByLabelText("Graph not available")).toHaveClass(
"text-text-neutral-secondary",
);
});
it("renders a tooltip explaining a completed scan without graph data", () => {
const completedNoGraph: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "completed",
graph_data_ready: false,
},
};
render(<ScanListTable scans={[completedNoGraph]} />);
expect(
screen.queryByLabelText("Graph data available"),
).not.toBeInTheDocument();
screen.getByRole("radio", { name: "Scan not available" }),
).toBeDisabled();
expect(
screen.getByText("This scan completed without producing graph data."),
).toBeInTheDocument();
});
});
@@ -1,9 +1,13 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Check, Minus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/shadcn/button/button";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import {
Tooltip,
TooltipContent,
@@ -38,44 +42,6 @@ const formatNullableDuration = (duration: number | null) => {
return formatDuration(duration);
};
const isSelectDisabled = (
scan: AttackPathScan,
selectedScanId: string | null,
) => {
return !scan.attributes.graph_data_ready || selectedScanId === scan.id;
};
const getSelectButtonLabel = (
scan: AttackPathScan,
selectedScanId: string | null,
) => {
if (selectedScanId === scan.id) {
return "Selected";
}
if (scan.attributes.graph_data_ready) {
return "Select";
}
if (scan.attributes.state === SCAN_STATES.SCHEDULED) {
return "Scheduled";
}
if (scan.attributes.state === SCAN_STATES.AVAILABLE) {
return "Queued";
}
if (scan.attributes.state === SCAN_STATES.EXECUTING) {
return "Running...";
}
if (scan.attributes.state === SCAN_STATES.FAILED) {
return "Failed";
}
return "Select";
};
const getDisabledTooltip = (scan: AttackPathScan): string | null => {
if (scan.attributes.graph_data_ready) {
return null;
@@ -97,7 +63,11 @@ const getDisabledTooltip = (scan: AttackPathScan): string | null => {
return "This scan failed. No graph data is available.";
}
return null;
if (scan.attributes.state === SCAN_STATES.COMPLETED) {
return "This scan completed without producing graph data.";
}
return "Graph data is not available for this scan.";
};
const getSelectedRowSelection = (
@@ -129,37 +99,65 @@ const buildMetadata = (
const getColumns = ({
selectedScanId,
onSelectScan,
}: {
selectedScanId: string | null;
onSelectScan: (scanId: string) => void;
}): ColumnDef<AttackPathScan>[] => [
{
id: "select",
header: () => <span className="text-sm font-medium">Select</span>,
cell: ({ row }) => {
const isSelected = selectedScanId === row.original.id;
const canSelect = row.original.attributes.graph_data_ready;
const tooltip = getDisabledTooltip(row.original);
const radio = (
<RadioGroupItem
value={row.original.id}
checked={isSelected}
disabled={!canSelect}
className={cn(
"size-5",
canSelect &&
!isSelected &&
"border-text-neutral-secondary cursor-pointer",
!canSelect && "disabled:opacity-70",
)}
aria-label={
isSelected
? "Selected scan"
: canSelect
? "Select scan"
: "Scan not available"
}
/>
);
if (!canSelect && !isSelected && tooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{radio}</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
return radio;
},
enableSorting: false,
},
{
accessorKey: "provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Account" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block size-2 shrink-0 rounded-full",
row.original.attributes.graph_data_ready
? "bg-bg-pass-primary"
: "bg-transparent",
)}
aria-label={
row.original.attributes.graph_data_ready
? "Graph data available"
: undefined
}
/>
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
</div>
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
),
enableSorting: false,
},
@@ -182,22 +180,32 @@ const getColumns = ({
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => (
<ScanStatusBadge
status={row.original.attributes.state}
progress={row.original.attributes.progress}
graphDataReady={row.original.attributes.graph_data_ready}
/>
<div className="flex">
<ScanStatusBadge
status={row.original.attributes.state}
progress={row.original.attributes.progress}
/>
</div>
),
enableSorting: false,
},
{
accessorKey: "progress",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Progress" />
),
cell: ({ row }) => (
<span className="text-sm">{row.original.attributes.progress}%</span>
),
accessorKey: "graph_data_ready",
header: () => <span className="text-sm font-medium">Graph</span>,
cell: ({ row }) =>
row.original.attributes.graph_data_ready ? (
<Check
size={16}
aria-label="Graph available"
className="text-text-success-primary"
/>
) : (
<Minus
size={16}
aria-label="Graph not available"
className="text-text-neutral-secondary"
/>
),
enableSorting: false,
},
{
@@ -212,45 +220,6 @@ const getColumns = ({
),
enableSorting: false,
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => {
const isDisabled = isSelectDisabled(row.original, selectedScanId);
const tooltip = getDisabledTooltip(row.original);
const button = (
<Button
type="button"
aria-label="Select scan"
disabled={isDisabled}
variant={isDisabled ? "secondary" : "default"}
onClick={() => onSelectScan(row.original.id)}
className="w-full max-w-24"
>
{getSelectButtonLabel(row.original, selectedScanId)}
</Button>
);
if (isDisabled && tooltip) {
return (
<div className="flex justify-end">
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full max-w-24" tabIndex={0}>
{button}
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</div>
);
}
return <div className="flex justify-end">{button}</div>;
},
enableSorting: false,
},
];
/**
@@ -300,19 +269,27 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
};
return (
<DataTable
columns={getColumns({
selectedScanId,
onSelectScan: handleSelectScan,
})}
data={paginatedScans}
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
<RadioGroup
value={selectedScanId ?? ""}
onValueChange={handleSelectScan}
className="gap-0"
>
<DataTable
columns={getColumns({ selectedScanId })}
data={paginatedScans}
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
</RadioGroup>
);
};
@@ -3,97 +3,55 @@
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import type { ScanState } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
const BADGE_CONFIG: Record<
ScanState,
{ className: string; label: string; showGraphDot: boolean }
> = {
const BADGE_CONFIG: Record<ScanState, { className: string; label: string }> = {
[SCAN_STATES.SCHEDULED]: {
className: "bg-bg-neutral-tertiary text-text-neutral-primary",
label: "Scheduled",
showGraphDot: true,
},
[SCAN_STATES.AVAILABLE]: {
className: "bg-bg-neutral-tertiary text-text-neutral-primary",
label: "Queued",
showGraphDot: true,
},
[SCAN_STATES.EXECUTING]: {
className: "bg-bg-warning-secondary text-text-neutral-primary",
className: "bg-bg-info-secondary text-text-info",
label: "In Progress",
showGraphDot: false,
},
[SCAN_STATES.COMPLETED]: {
className: "bg-bg-pass-secondary text-text-success-primary",
label: "Completed",
showGraphDot: false,
},
[SCAN_STATES.FAILED]: {
className: "bg-bg-fail-secondary text-text-error-primary",
label: "Failed",
showGraphDot: true,
},
};
interface ScanStatusBadgeProps {
status: ScanState;
progress?: number;
graphDataReady?: boolean;
}
export const ScanStatusBadge = ({
status,
progress = 0,
graphDataReady = false,
}: ScanStatusBadgeProps) => {
const config = BADGE_CONFIG[status];
const graphDot = graphDataReady && config.showGraphDot && (
<span className="bg-bg-pass-primary inline-block size-2 rounded-full" />
);
const tooltipText = graphDataReady
? "Graph available"
: status === SCAN_STATES.FAILED || status === SCAN_STATES.COMPLETED
? "Graph not available"
: "Graph not available yet";
const icon =
status === SCAN_STATES.EXECUTING ? (
<Loader2
size={14}
className={
graphDataReady
? "text-text-success-primary animate-spin"
: "animate-spin"
}
/>
) : (
graphDot
);
const label =
status === SCAN_STATES.EXECUTING
? `${config.label} (${progress}%)`
: config.label;
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge className={cn(config.className, "gap-2")}>
{icon}
<span>{label}</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
<Badge className={cn(config.className, "gap-2")}>
{status === SCAN_STATES.EXECUTING && (
<Loader2 size={14} className="animate-spin" />
)}
<span>{label}</span>
</Badge>
);
};
@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Info, Maximize2, TriangleAlert, X } from "lucide-react";
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -120,8 +120,8 @@ export default function AttackPathsPage() {
// Check if there's an executing scan for auto-refresh
const hasExecutingScan = scans.some(
(scan) =>
scan.attributes.state === "executing" ||
scan.attributes.state === "scheduled",
scan.attributes.state === SCAN_STATES.EXECUTING ||
scan.attributes.state === SCAN_STATES.SCHEDULED,
);
// Detect if the selected scan is showing data from a previous cycle
@@ -358,11 +358,11 @@ export default function AttackPathsPage() {
<h2 className="dark:text-prowler-theme-pale/90 text-xl font-semibold">
Attack Paths
</h2>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-sm">
<p className="text-text-neutral-secondary mt-2 text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
</p>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-xs">
<p className="text-text-neutral-secondary mt-1 text-xs">
Scans can be selected when data is available. A new scan does not
interrupt access to existing data.
</p>
@@ -394,11 +394,8 @@ export default function AttackPathsPage() {
{/* Banner: viewing data from a previous scan cycle */}
{isViewingPreviousCycleData && (
<Alert
variant="default"
className="border-border-warning-secondary bg-bg-warning-secondary"
>
<TriangleAlert className="text-text-warning-primary size-4" />
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>Viewing data from a previous scan</AlertTitle>
<AlertDescription>
This scan is currently{" "}
@@ -605,7 +602,7 @@ export default function AttackPathsPage() {
<X size={16} />
</Button>
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
<p className="text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label
@@ -628,7 +625,7 @@ export default function AttackPathsPage() {
<h4 className="mb-2 text-xs font-semibold">
Type
</h4>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
<p className="text-text-neutral-secondary text-xs">
{graphState.selectedNode?.labels
.map(formatNodeLabel)
.join(", ")}
@@ -678,7 +675,7 @@ export default function AttackPathsPage() {
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
<p className="text-text-neutral-secondary mt-1 text-sm">
{String(
graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),