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}