Compare commits

...

2 Commits

Author SHA1 Message Date
Alan Buscaglia
fcbabca2c1 fix(ui): resolve lint and prettier violations from CI 2026-04-14 16:23:41 +02:00
Alan Buscaglia
ac6901d4bb feat(ui): improve attack paths scan selection UX
- contextual button labels based on graph_data_ready instead of scan state
- tooltip on disabled select button explaining why it cannot be selected
- green dot indicator on rows where graph data is available
- warning banner when viewing data from a previous scan cycle
2026-04-14 16:14:28 +02:00
5 changed files with 338 additions and 29 deletions

115
.opencode/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.23.1] (Prowler UNRELEASED)
### 🔄 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
- 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)

View File

@@ -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 }) => (
<span data-testid="tooltip-content">{children}</span>
),
}));
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(<ScanListTable scans={[scheduledScan]} />);
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(<ScanListTable scans={[executingScan]} />);
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(<ScanListTable scans={[scheduledWithGraph]} />);
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(<ScanListTable scans={[createScan(1)]} />);
const dot = screen.getByLabelText("Graph data available");
expect(dot).toBeInTheDocument();
expect(dot).toHaveClass("bg-green-500");
});
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(<ScanListTable scans={[noGraphScan]} />);
expect(
screen.queryByLabelText("Graph data available"),
).not.toBeInTheDocument();
});
});

View File

@@ -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 = ({
<DataTableColumnHeader column={column} title="Account" />
),
cell: ({ row }) => (
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block size-2 shrink-0 rounded-full",
row.original.attributes.graph_data_ready
? "bg-green-500"
: "bg-transparent",
)}
aria-label={
row.original.attributes.graph_data_ready
? "Graph data available"
: undefined
}
/>
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
</div>
),
enableSorting: false,
},
@@ -170,21 +217,37 @@ const getColumns = ({
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => {
const isDisabled = isSelectDisabled(row.original, selectedScanId);
const tooltip = getDisabledTooltip(row.original);
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 && tooltip) {
return (
<div className="flex justify-end">
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full max-w-24" tabIndex={0}>
{button}
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</div>
);
}
return <div className="flex justify-end">{button}</div>;
},
enableSorting: false,
},

View File

@@ -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() {
<ScanListTable scans={scans} />
</Suspense>
{/* Banner: viewing data from a previous scan cycle */}
{isViewingPreviousCycleData && (
<Alert
variant="default"
className="border-border-warning-secondary bg-bg-warning-secondary"
>
<TriangleAlert className="text-text-warning-primary size-4" />
<AlertTitle>Viewing data from a previous scan</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
)}
{/* Query Builder Section - shown only after selecting a scan */}
{scanId && (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">