diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json
new file mode 100644
index 0000000000..86bbf5645f
--- /dev/null
+++ b/.opencode/package-lock.json
@@ -0,0 +1,115 @@
+{
+ "name": ".opencode",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@opencode-ai/plugin": "1.3.17"
+ }
+ },
+ "node_modules/@opencode-ai/plugin": {
+ "version": "1.3.17",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz",
+ "integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@opencode-ai/sdk": "1.3.17",
+ "zod": "4.1.8"
+ },
+ "peerDependencies": {
+ "@opentui/core": ">=0.1.96",
+ "@opentui/solid": ">=0.1.96"
+ },
+ "peerDependenciesMeta": {
+ "@opentui/core": {
+ "optional": true
+ },
+ "@opentui/solid": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@opencode-ai/sdk": {
+ "version": "1.3.17",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz",
+ "integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "7.0.6"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.1.8",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index b696248687..4affd8594b 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -9,6 +9,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- Resources side drawer with redesigned detail panel [(#10673)](https://github.com/prowler-cloud/prowler/pull/10673)
- Syntax highlighting for remediation code blocks in finding groups drawer with provider-aware auto-detection (Shell, HCL, YAML, Bicep) [(#10698)](https://github.com/prowler-cloud/prowler/pull/10698)
+### 🔄 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)
+
### 🐞 Fixed
- 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)
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..fb1781ff49 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,19 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => navigationState.searchParams,
}));
+vi.mock("@/components/shadcn/tooltip", () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipTrigger: ({
+ children,
+ }: {
+ children: ReactNode;
+ asChild?: boolean;
+ }) => <>{children}>,
+ TooltipContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+}));
+
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
@@ -203,4 +216,93 @@ describe("ScanListTable", () => {
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Failed");
});
+
+ it("shows 'Scheduled' label for a scheduled scan without graph data", () => {
+ const scheduledScan: AttackPathScan = {
+ ...createScan(1),
+ attributes: {
+ ...createScan(1).attributes,
+ state: "scheduled",
+ progress: 0,
+ graph_data_ready: false,
+ completed_at: null,
+ duration: null,
+ },
+ };
+
+ render();
+
+ const button = screen.getByRole("button", { name: "Select scan" });
+ expect(button).toBeDisabled();
+ expect(button).toHaveTextContent("Scheduled");
+ });
+
+ it("shows 'Running...' label for an executing scan without graph data", () => {
+ const executingScan: AttackPathScan = {
+ ...createScan(1),
+ attributes: {
+ ...createScan(1).attributes,
+ state: "executing",
+ progress: 45,
+ graph_data_ready: false,
+ completed_at: null,
+ duration: null,
+ },
+ };
+
+ render();
+
+ const button = screen.getByRole("button", { name: "Select scan" });
+ expect(button).toBeDisabled();
+ expect(button).toHaveTextContent("Running...");
+ });
+
+ it("enables Select for a scheduled scan when graph data is ready from a previous cycle", async () => {
+ const user = userEvent.setup();
+ const scheduledWithGraph: AttackPathScan = {
+ ...createScan(1),
+ attributes: {
+ ...createScan(1).attributes,
+ state: "scheduled",
+ progress: 0,
+ 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("shows a green dot next to the account name when graph data is ready", () => {
+ render();
+
+ const dot = screen.getByLabelText("Graph data available");
+ expect(dot).toBeInTheDocument();
+ expect(dot).toHaveClass("bg-bg-pass-primary");
+ });
+
+ it("does not show a green dot when graph data is not ready", () => {
+ const noGraphScan: AttackPathScan = {
+ ...createScan(1),
+ attributes: {
+ ...createScan(1).attributes,
+ graph_data_ready: false,
+ },
+ };
+
+ render();
+
+ expect(
+ screen.queryByLabelText("Graph data available"),
+ ).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..2730674af1 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,12 +4,18 @@ 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 { cn } from "@/lib/utils";
import type { MetaDataProps, ProviderType } from "@/types";
-import type { AttackPathScan, ScanState } from "@/types/attack-paths";
+import type { AttackPathScan } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
import { ScanStatusBadge } from "./scan-status-badge";
@@ -20,12 +26,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,8 +57,16 @@ const getSelectButtonLabel = (
return "Select";
}
- if (WAITING_STATES.includes(scan.attributes.state)) {
- return "Waiting...";
+ 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) {
@@ -68,6 +76,30 @@ const getSelectButtonLabel = (
return "Select";
};
+const getDisabledTooltip = (scan: AttackPathScan): string | null => {
+ if (scan.attributes.graph_data_ready) {
+ return null;
+ }
+
+ if (scan.attributes.state === SCAN_STATES.SCHEDULED) {
+ return "Graph will be available once this scan runs and completes.";
+ }
+
+ if (scan.attributes.state === SCAN_STATES.AVAILABLE) {
+ return "This scan is queued. Graph will be available once it completes.";
+ }
+
+ if (scan.attributes.state === SCAN_STATES.EXECUTING) {
+ return "Scan is running. Graph will be available once it completes.";
+ }
+
+ if (scan.attributes.state === SCAN_STATES.FAILED) {
+ return "This scan failed. No graph data is available.";
+ }
+
+ return null;
+};
+
const getSelectedRowSelection = (
scans: AttackPathScan[],
selectedScanId: string | null,
@@ -108,11 +140,26 @@ const getColumns = ({
),
cell: ({ row }) => (
-
+
+
+
+
),
enableSorting: false,
},
@@ -170,21 +217,37 @@ const getColumns = ({
header: () => Actions,
cell: ({ row }) => {
const isDisabled = isSelectDisabled(row.original, selectedScanId);
+ const tooltip = getDisabledTooltip(row.original);
- return (
-
-
-
+ const button = (
+
);
+
+ if (isDisabled && tooltip) {
+ return (
+
+
+
+
+ {button}
+
+
+ {tooltip}
+
+
+ );
+ }
+
+ 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..40d5c4e4c6 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";
@@ -56,7 +57,7 @@ export const ScanStatusBadge = ({
const config = BADGE_CONFIG[status];
const graphDot = graphDataReady && config.showGraphDot && (
-
+
);
const tooltipText = graphDataReady
@@ -70,7 +71,9 @@ export const ScanStatusBadge = ({
) : (
@@ -85,7 +88,7 @@ export const ScanStatusBadge = ({
return (
-
+
{icon}
{label}
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
index f6b1007f5a..4a9cfe84b7 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
+import { ArrowLeft, Info, Maximize2, TriangleAlert, X } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -37,7 +37,7 @@ import type {
AttackPathScan,
GraphNode,
} from "@/types/attack-paths";
-import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
+import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths";
import {
AttackPathGraph,
@@ -120,6 +120,13 @@ export default function AttackPathsPage() {
scan.attributes.state === "scheduled",
);
+ // Detect if the selected scan is showing data from a previous cycle
+ const selectedScan = scans.find((scan) => scan.id === scanId);
+ const isViewingPreviousCycleData =
+ selectedScan &&
+ selectedScan.attributes.graph_data_ready &&
+ selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
+
// Callback to refresh scans (used by AutoRefresh component)
const refreshScans = async () => {
try {
@@ -373,6 +380,24 @@ export default function AttackPathsPage() {
+ {/* Banner: viewing data from a previous scan cycle */}
+ {isViewingPreviousCycleData && (
+
+
+ Viewing data from a previous scan
+
+ This scan is currently{" "}
+ {selectedScan.attributes.state === SCAN_STATES.EXECUTING
+ ? `running (${selectedScan.attributes.progress}%)`
+ : selectedScan.attributes.state}
+ . The graph data shown is from the last completed cycle.
+
+
+ )}
+
{/* Query Builder Section - shown only after selecting a scan */}
{scanId && (