diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx index a029481e7a..79df7914fd 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx @@ -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; + }) =>
{children}
, + TooltipContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("./scan-status-badge", () => ({ + ScanStatusBadge: ({ + status, + graphDataReady, + }: { + status: string; + graphDataReady?: boolean; + }) => ( + + {status} + {graphDataReady && " (graph ready)"} + + ), +})); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Graph data not yet available", + ); + }); + + it("does not show tooltip on enabled button", () => { + render(); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx index b5bd240cf0..7b7fcc14db 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -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 ( -
- -
+ const button = ( + ); + + if (isDisabled && selectedScanId !== row.original.id) { + return ( +
+ + + + {button} + + + Graph data not yet available + +
+ ); + } + + return
{button}
; }, enableSorting: false, }, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx index de8833749e..3fa0957f0b 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx @@ -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 && ( + + ); + const icon = status === SCAN_STATES.EXECUTING ? ( - + <> + {graphDot} + {spinner} + ) : ( graphDot ); @@ -85,7 +88,7 @@ export const ScanStatusBadge = ({ return ( - + {icon} {label}