From 8d8bee165bea3c566a89ea535b662627e89dce53 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 15 Apr 2026 13:54:25 +0200 Subject: [PATCH] feat(ui): improve attack paths scan selection UX (#10685) --- .opencode/package-lock.json | 115 +++++++++++++++++ ui/CHANGELOG.md | 4 + .../_components/scan-list-table.test.tsx | 102 +++++++++++++++ .../_components/scan-list-table.tsx | 117 ++++++++++++++---- .../_components/scan-status-badge.tsx | 9 +- .../query-builder/attack-paths-page.tsx | 29 ++++- 6 files changed, 344 insertions(+), 32 deletions(-) create mode 100644 .opencode/package-lock.json 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 && (