Compare commits

...

2 Commits

Author SHA1 Message Date
Pablo F.G
dd66c95cea docs(ui): add PROWLER-1383 changelog entry for attack path scan selector fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:48:07 +02:00
Pablo F.G
645424242a fix(ui): make attack path scan selector indicate and allow selecting latest available scan
Button labels now reflect graph_data_ready instead of scan execution
state, disabled buttons show a tooltip explaining why selection is
unavailable, and the green dot visual cue appears on all scan states
when graph data is ready.

Closes PROWLER-1383

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:46:36 +02:00
4 changed files with 187 additions and 39 deletions

View File

@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Findings group resource filters now strip unsupported scan parameters, display scan name instead of provider alias in filter badges, migrate mute modal from HeroUI to shadcn, and add searchable accounts/provider type selectors [(#10662)](https://github.com/prowler-cloud/prowler/pull/10662)
- Compliance detail page header now reflects the actual provider, alias and UID of the selected scan instead of always defaulting to AWS [(#10674)](https://github.com/prowler-cloud/prowler/pull/10674)
- Attack Path scan selector now labels buttons based on `graph_data_ready` instead of scan state, shows tooltip on disabled buttons, and displays green dot on all scan states when graph data is available [(#10694)](https://github.com/prowler-cloud/prowler/pull/10694)
---

View File

@@ -24,6 +24,36 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => navigationState.searchParams,
}));
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({
children,
asChild: _asChild,
...props
}: {
children: ReactNode;
asChild?: boolean;
}) => <div {...props}>{children}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<div role="tooltip">{children}</div>
),
}));
vi.mock("./scan-status-badge", () => ({
ScanStatusBadge: ({
status,
graphDataReady,
}: {
status: string;
graphDataReady?: boolean;
}) => (
<span>
{status}
{graphDataReady && " (graph ready)"}
</span>
),
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
@@ -201,6 +231,114 @@ describe("ScanListTable", () => {
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Failed");
expect(button).toHaveTextContent("Unavailable");
});
// PROWLER-1383: Button label based on graph_data_ready instead of scan state
it("shows 'Unavailable' for scheduled scan when graph data is not ready", () => {
const scheduledScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "scheduled",
graph_data_ready: false,
},
};
render(<ScanListTable scans={[scheduledScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Unavailable");
});
it("shows 'Unavailable' for executing scan when graph data is not ready", () => {
const executingScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "executing",
progress: 45,
graph_data_ready: false,
},
};
render(<ScanListTable scans={[executingScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Unavailable");
});
// PROWLER-1383: Enable Select on scheduled/executing scans with graph data from previous cycle
it("enables 'Select' for executing scan when graph data is ready from previous cycle", async () => {
const user = userEvent.setup();
const executingScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "executing",
progress: 30,
graph_data_ready: true,
},
};
render(<ScanListTable scans={[executingScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeEnabled();
expect(button).toHaveTextContent("Select");
await user.click(button);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("enables 'Select' for scheduled scan when graph data is ready from previous cycle", async () => {
const user = userEvent.setup();
const scheduledScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "scheduled",
graph_data_ready: true,
},
};
render(<ScanListTable scans={[scheduledScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeEnabled();
expect(button).toHaveTextContent("Select");
await user.click(button);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
// PROWLER-1383: Tooltip on disabled button explaining why it can't be selected
it("shows tooltip on disabled button explaining graph data is not available", () => {
const unavailableScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "executing",
graph_data_ready: false,
},
};
render(<ScanListTable scans={[unavailableScan]} />);
expect(screen.getByRole("tooltip")).toHaveTextContent(
"Graph data not yet available",
);
});
it("does not show tooltip on enabled button", () => {
render(<ScanListTable scans={[createScan(1)]} />);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});

View File

@@ -4,13 +4,17 @@ import { ColumnDef } from "@tanstack/react-table";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/shadcn/button/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { DataTable, DataTableColumnHeader } from "@/components/ui/table";
import { formatDuration } from "@/lib/date-utils";
import type { MetaDataProps, ProviderType } from "@/types";
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
import type { AttackPathScan } from "@/types/attack-paths";
import { ScanStatusBadge } from "./scan-status-badge";
@@ -20,11 +24,6 @@ interface ScanListTableProps {
const DEFAULT_PAGE_SIZE = 5;
const PAGE_SIZE_OPTIONS = [2, 5, 10, 15];
const WAITING_STATES: readonly ScanState[] = [
SCAN_STATES.SCHEDULED,
SCAN_STATES.AVAILABLE,
SCAN_STATES.EXECUTING,
];
const parsePageParam = (value: string | null, fallback: number) => {
if (!value) return fallback;
@@ -57,15 +56,7 @@ const getSelectButtonLabel = (
return "Select";
}
if (WAITING_STATES.includes(scan.attributes.state)) {
return "Waiting...";
}
if (scan.attributes.state === SCAN_STATES.FAILED) {
return "Failed";
}
return "Select";
return "Unavailable";
};
const getSelectedRowSelection = (
@@ -171,20 +162,35 @@ const getColumns = ({
cell: ({ row }) => {
const isDisabled = isSelectDisabled(row.original, selectedScanId);
return (
<div className="flex justify-end">
<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>
</div>
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 && selectedScanId !== row.original.id) {
return (
<div className="flex justify-end">
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full max-w-24" tabIndex={0}>
{button}
</span>
</TooltipTrigger>
<TooltipContent>Graph data not yet available</TooltipContent>
</Tooltip>
</div>
);
}
return <div className="flex justify-end">{button}</div>;
},
enableSorting: false,
},

View File

@@ -8,6 +8,7 @@ import {
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";
@@ -28,12 +29,12 @@ const BADGE_CONFIG: Record<
[SCAN_STATES.EXECUTING]: {
className: "bg-bg-warning-secondary text-text-neutral-primary",
label: "In Progress",
showGraphDot: false,
showGraphDot: true,
},
[SCAN_STATES.COMPLETED]: {
className: "bg-bg-pass-secondary text-text-success-primary",
label: "Completed",
showGraphDot: false,
showGraphDot: true,
},
[SCAN_STATES.FAILED]: {
className: "bg-bg-fail-secondary text-text-error-primary",
@@ -65,14 +66,16 @@ export const ScanStatusBadge = ({
? "Graph not available"
: "Graph not available yet";
const spinner = status === SCAN_STATES.EXECUTING && (
<Loader2 size={14} className="animate-spin" />
);
const icon =
status === SCAN_STATES.EXECUTING ? (
<Loader2
size={14}
className={
graphDataReady ? "animate-spin text-green-500" : "animate-spin"
}
/>
<>
{graphDot}
{spinner}
</>
) : (
graphDot
);
@@ -85,7 +88,7 @@ export const ScanStatusBadge = ({
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge className={`${config.className} gap-2`}>
<Badge className={cn(config.className, "gap-2")}>
{icon}
<span>{label}</span>
</Badge>