feat(attack-paths): add graph_data_ready field to decouple query availability from scan state (#10089)

Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
Josema Camacho
2026-02-17 17:29:36 +01:00
committed by GitHub
parent ff25d6a8c2
commit 7698cdce2e
17 changed files with 860 additions and 366 deletions

View File

@@ -13,6 +13,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Attack Paths: Query list now shows their name and short description, when one is selected it also shows a longer description and an attribution if it has it [(#9983)](https://github.com/prowler-cloud/prowler/pull/9983)
- Updated GitHub provider form placeholder to clarify both username and organization names are valid inputs [(#9830)](https://github.com/prowler-cloud/prowler/pull/9830)
- CSA CCM detailed view and small fix related with `Top Failed Sections` width [(#10018)](https://github.com/prowler-cloud/prowler/pull/10018)
- Attack Paths: Show scan data availability status with badges and tooltips, allow selecting scans for querying while a new scan is in progress [(#10089)](https://github.com/prowler-cloud/prowler/pull/10089)
### 🔐 Security

View File

@@ -30,7 +30,7 @@ import {
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { ProviderType } from "@/types";
import type { AttackPathScan } from "@/types/attack-paths";
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
import { ScanStatusBadge } from "./scan-status-badge";
@@ -42,6 +42,11 @@ interface ScanListTableProps {
const TABLE_COLUMN_COUNT = 6;
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 baseLinkClass =
"relative block rounded border-0 bg-transparent px-3 py-1.5 text-button-primary outline-none transition-all duration-300 hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary focus:shadow-none dark:hover:bg-bg-neutral-secondary dark:hover:text-text-neutral-primary";
@@ -77,20 +82,17 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
};
const isSelectDisabled = (scan: AttackPathScan) => {
return (
scan.attributes.state !== SCAN_STATES.COMPLETED ||
selectedScanId === scan.id
);
return !scan.attributes.graph_data_ready || selectedScanId === scan.id;
};
const getSelectButtonLabel = (scan: AttackPathScan) => {
if (selectedScanId === scan.id) {
return "Selected";
}
if (scan.attributes.state === SCAN_STATES.SCHEDULED) {
return "Scheduled";
if (scan.attributes.graph_data_ready) {
return "Select";
}
if (scan.attributes.state === SCAN_STATES.EXECUTING) {
if (WAITING_STATES.includes(scan.attributes.state)) {
return "Waiting...";
}
if (scan.attributes.state === SCAN_STATES.FAILED) {
@@ -184,6 +186,7 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
<ScanStatusBadge
status={scan.attributes.state}
progress={scan.attributes.progress}
graphDataReady={scan.attributes.graph_data_ready}
/>
</TableCell>
<TableCell>
@@ -342,8 +345,8 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
)}
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-6 text-xs">
Only Attack Paths scans with &quot;Completed&quot; status can be
selected. Scans in progress will update automatically.
Scans can be selected when data is available. A new scan does not
interrupt access to existing data.
</p>
</>
);

View File

@@ -3,57 +3,94 @@
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import type { ScanState } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
const BADGE_CONFIG: Record<
ScanState,
{ className: string; label: string; showGraphDot: boolean }
> = {
[SCAN_STATES.SCHEDULED]: {
className: "bg-bg-neutral-tertiary text-text-neutral-primary",
label: "Scheduled",
showGraphDot: true,
},
[SCAN_STATES.AVAILABLE]: {
className: "bg-bg-neutral-tertiary text-text-neutral-primary",
label: "Queued",
showGraphDot: true,
},
[SCAN_STATES.EXECUTING]: {
className: "bg-bg-warning-secondary text-text-neutral-primary",
label: "In Progress",
showGraphDot: false,
},
[SCAN_STATES.COMPLETED]: {
className: "bg-bg-pass-secondary text-text-success-primary",
label: "Completed",
showGraphDot: false,
},
[SCAN_STATES.FAILED]: {
className: "bg-bg-fail-secondary text-text-error-primary",
label: "Failed",
showGraphDot: true,
},
};
interface ScanStatusBadgeProps {
status: ScanState;
progress?: number;
graphDataReady?: boolean;
}
/**
* Status badge for attack path scan status
* Shows visual indicator and text for scan progress
*/
export const ScanStatusBadge = ({
status,
progress = 0,
graphDataReady = false,
}: ScanStatusBadgeProps) => {
if (status === "scheduled") {
return (
<Badge className="bg-bg-neutral-tertiary text-text-neutral-primary gap-2">
<span>Scheduled</span>
</Badge>
);
}
const config = BADGE_CONFIG[status];
if (status === "available") {
return (
<Badge className="bg-bg-neutral-tertiary text-text-neutral-primary gap-2">
<span>Queued</span>
</Badge>
);
}
const graphDot = graphDataReady && config.showGraphDot && (
<span className="inline-block size-2 rounded-full bg-green-500" />
);
if (status === "executing") {
return (
<Badge className="bg-bg-warning-secondary text-text-neutral-primary gap-2">
<Loader2 size={14} className="animate-spin" />
<span>In Progress ({progress}%)</span>
</Badge>
);
}
const tooltipText = graphDataReady
? "Graph available"
: status === SCAN_STATES.FAILED || status === SCAN_STATES.COMPLETED
? "Graph not available"
: "Graph not available yet";
if (status === "completed") {
return (
<Badge className="bg-bg-pass-secondary text-text-success-primary gap-2">
<span>Completed</span>
</Badge>
const icon =
status === SCAN_STATES.EXECUTING ? (
<Loader2
size={14}
className={
graphDataReady ? "animate-spin text-green-500" : "animate-spin"
}
/>
) : (
graphDot
);
}
const label =
status === SCAN_STATES.EXECUTING
? `${config.label} (${progress}%)`
: config.label;
return (
<Badge className="bg-bg-fail-secondary text-text-error-primary gap-2">
<span>Failed</span>
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge className={`${config.className} gap-2`}>
{icon}
<span>{label}</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
};

View File

@@ -43,6 +43,7 @@ export type ProviderType = (typeof PROVIDER_TYPES)[keyof typeof PROVIDER_TYPES];
export interface AttackPathScanAttributes {
state: ScanState;
progress: number;
graph_data_ready: boolean;
provider_alias: string;
provider_type: ProviderType;
provider_uid: string;

View File

@@ -1,93 +1,106 @@
import { PaginationLinks, RelationshipWrapper } from "./attack-paths";
import { ProviderType } from "./providers";
export interface ScannerArgs {
only_logs?: boolean;
excluded_checks?: string[];
aws_retries_max_attempts?: number;
}
export const SCAN_TRIGGER = {
SCHEDULED: "scheduled",
MANUAL: "manual",
} as const;
export type ScanTrigger = (typeof SCAN_TRIGGER)[keyof typeof SCAN_TRIGGER];
export const SCAN_STATE = {
AVAILABLE: "available",
SCHEDULED: "scheduled",
EXECUTING: "executing",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type ScanState = (typeof SCAN_STATE)[keyof typeof SCAN_STATE];
export interface ScanAttributes {
name: string;
trigger: ScanTrigger;
state: ScanState;
unique_resource_count: number;
progress: number;
scanner_args: ScannerArgs | null;
duration: number;
started_at: string;
inserted_at: string;
completed_at: string;
scheduled_at: string;
next_scan_at: string;
}
export interface ScanRelationships {
provider: RelationshipWrapper;
task: RelationshipWrapper;
}
export interface ScanProviderInfo {
provider: ProviderType;
uid: string;
alias: string;
}
export interface ScanProps {
type: "scans";
id: string;
attributes: {
name: string;
trigger: "scheduled" | "manual";
state:
| "available"
| "scheduled"
| "executing"
| "completed"
| "failed"
| "cancelled";
unique_resource_count: number;
progress: number;
scanner_args: {
only_logs?: boolean;
excluded_checks?: string[];
aws_retries_max_attempts?: number;
} | null;
duration: number;
started_at: string;
inserted_at: string;
completed_at: string;
scheduled_at: string;
next_scan_at: string;
};
relationships: {
provider: {
data: {
id: string;
type: "providers";
};
};
task: {
data: {
id: string;
type: "tasks";
};
};
};
providerInfo?: {
provider: ProviderType;
uid: string;
alias: string;
};
attributes: ScanAttributes;
relationships: ScanRelationships;
providerInfo?: ScanProviderInfo;
}
export interface ScanEntityProviderInfo {
provider: ProviderType;
alias?: string;
uid?: string;
}
export interface ScanEntityAttributes {
name?: string;
completed_at: string;
}
export interface ScanEntity {
id: string;
providerInfo: {
provider: ProviderType;
alias?: string;
uid?: string;
};
attributes: {
name?: string;
completed_at: string;
};
providerInfo: ScanEntityProviderInfo;
attributes: ScanEntityAttributes;
}
export interface ExpandedScanData extends ScanProps {
providerInfo: {
provider: ProviderType;
uid: string;
alias: string;
};
providerInfo: ScanProviderInfo;
}
export interface IncludedResource {
type: string;
id: string;
attributes: any;
relationships?: any;
}
export interface ApiPagination {
page: number;
pages: number;
count: number;
}
export interface ScansApiMeta {
pagination: ApiPagination;
version: string;
}
export interface ScansApiResponse {
links: {
first: string;
last: string;
next: string | null;
prev: string | null;
};
links: PaginationLinks;
data: ScanProps[];
included?: Array<{
type: string;
id: string;
attributes: any;
relationships?: any;
}>;
meta: {
pagination: {
page: number;
pages: number;
count: number;
};
version: string;
};
included?: IncludedResource[];
meta: ScansApiMeta;
}