mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
refactor(ui): attack paths restyling and component migrations (#10310)
This commit is contained in:
@@ -12,7 +12,7 @@ export const WorkflowAttackPaths = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Determine current step based on pathname
|
||||
const isQueryBuilderStep = pathname.includes("query-builder");
|
||||
const isQueryBuilderStep = pathname.includes("/attack-paths");
|
||||
|
||||
const currentStep = isQueryBuilderStep ? 1 : 0; // 0-indexed
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Navbar } from "@/components/ui/nav-bar/navbar";
|
||||
|
||||
/**
|
||||
* Workflow layout for Attack Paths
|
||||
* Displays content with navbar
|
||||
*/
|
||||
export default function AttackPathsWorkflowLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Navbar title="Attack Paths Analysis" icon="" />
|
||||
<div className="px-6 py-4 sm:px-8 xl:px-10">
|
||||
{/* Content */}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GraphLoading } from "./graph-loading";
|
||||
|
||||
describe("GraphLoading", () => {
|
||||
it("uses the provider wizard loading pattern", () => {
|
||||
render(<GraphLoading />);
|
||||
|
||||
expect(screen.getByTestId("graph-loading")).toHaveClass(
|
||||
"flex",
|
||||
"min-h-[320px]",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
"gap-4",
|
||||
"text-center",
|
||||
);
|
||||
expect(screen.getByLabelText("Loading")).toHaveClass(
|
||||
"size-6",
|
||||
"animate-spin",
|
||||
);
|
||||
expect(screen.getByText("Loading Attack Paths graph...")).toHaveClass(
|
||||
"text-muted-foreground",
|
||||
"text-sm",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
import { QueryParametersForm } from "./query-parameters-form";
|
||||
|
||||
const mockQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: "query-with-string-parameter",
|
||||
attributes: {
|
||||
name: "Query With String Parameter",
|
||||
short_description: "Requires a tag key",
|
||||
description: "Returns buckets filtered by tag",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "tag_key",
|
||||
label: "Tag key",
|
||||
data_type: "string",
|
||||
description: "Tag key to filter the S3 bucket.",
|
||||
placeholder: "DataClassification",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function TestForm() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
tag_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<QueryParametersForm selectedQuery={mockQuery} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("QueryParametersForm", () => {
|
||||
it("uses the field description as the placeholder instead of rendering helper text below", () => {
|
||||
// Given
|
||||
render(<TestForm />);
|
||||
|
||||
// When
|
||||
const input = screen.getByRole("textbox", { name: /tag key/i });
|
||||
|
||||
// Then
|
||||
expect(input).toHaveAttribute("data-slot", "input");
|
||||
expect(input).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Tag key to filter the S3 bucket.",
|
||||
);
|
||||
expect(screen.getByTestId("query-parameters-grid")).toHaveClass(
|
||||
"grid",
|
||||
"grid-cols-1",
|
||||
"md:grid-cols-2",
|
||||
);
|
||||
expect(screen.getByText("Tag key")).toHaveClass(
|
||||
"text-text-neutral-tertiary",
|
||||
"text-xs",
|
||||
"font-medium",
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Tag key to filter the S3 bucket."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export const QuerySelector = ({
|
||||
return (
|
||||
<Select value={selectedQueryId || ""} onValueChange={onQueryChange}>
|
||||
<SelectTrigger className="w-full text-left">
|
||||
<SelectValue placeholder="Choose a query..." />
|
||||
<SelectValue placeholder="Choose a query" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{queries.map((query) => (
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AttackPathScan } from "@/types/attack-paths";
|
||||
|
||||
import { ScanListTable } from "./scan-list-table";
|
||||
|
||||
const { pushMock, navigationState } = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
navigationState: {
|
||||
pathname: "/attack-paths",
|
||||
searchParams: new URLSearchParams("scanPage=1&scanPageSize=5"),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navigationState.pathname,
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
useSearchParams: () => navigationState.searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: ({
|
||||
entityAlias,
|
||||
entityId,
|
||||
}: {
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
}) => <div>{entityAlias ?? entityId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
DataTable: ({
|
||||
columns,
|
||||
data,
|
||||
metadata,
|
||||
controlledPage,
|
||||
}: {
|
||||
columns: Array<{
|
||||
id?: string;
|
||||
header?:
|
||||
| string
|
||||
| ((context: { column: { getCanSort: () => boolean } }) => ReactNode);
|
||||
cell?: (context: { row: { original: AttackPathScan } }) => ReactNode;
|
||||
}>;
|
||||
data: AttackPathScan[];
|
||||
metadata: {
|
||||
pagination: {
|
||||
count: number;
|
||||
pages: number;
|
||||
};
|
||||
};
|
||||
controlledPage: number;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{metadata.pagination.count} Total Entries</span>
|
||||
<span>
|
||||
Page {controlledPage} of {metadata.pagination.pages}
|
||||
</span>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th key={column.id ?? index}>
|
||||
{typeof column.header === "function"
|
||||
? flexRender(column.header, {
|
||||
column: { getCanSort: () => false },
|
||||
})
|
||||
: column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{columns.map((column, index) => (
|
||||
<td key={column.id ?? index}>
|
||||
{column.cell
|
||||
? flexRender(column.cell, { row: { original: row } })
|
||||
: null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const createScan = (id: number): AttackPathScan => ({
|
||||
type: "attack-paths-scans",
|
||||
id: `scan-${id}`,
|
||||
attributes: {
|
||||
state: "completed",
|
||||
progress: 100,
|
||||
graph_data_ready: true,
|
||||
provider_alias: `Provider ${id}`,
|
||||
provider_type: "aws",
|
||||
provider_uid: `1234567890${id}`,
|
||||
inserted_at: "2026-03-11T10:00:00Z",
|
||||
started_at: "2026-03-11T10:00:00Z",
|
||||
completed_at: "2026-03-11T10:05:00Z",
|
||||
duration: 300,
|
||||
},
|
||||
relationships: {
|
||||
provider: {
|
||||
data: {
|
||||
type: "providers",
|
||||
id: `provider-${id}`,
|
||||
},
|
||||
},
|
||||
scan: {
|
||||
data: {
|
||||
type: "scans",
|
||||
id: `base-scan-${id}`,
|
||||
},
|
||||
},
|
||||
task: {
|
||||
data: {
|
||||
type: "tasks",
|
||||
id: `task-${id}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("ScanListTable", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"scanPage=1&scanPageSize=5",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the shared data table chrome and preserves query params when selecting a scan", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScanListTable
|
||||
scans={Array.from({ length: 12 }, (_, index) => createScan(index + 1))}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("12 Total Entries")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByRole("button", { name: "Select scan" })[0]);
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderType } from "@/types";
|
||||
import { DataTable, DataTableColumnHeader } from "@/components/ui/table";
|
||||
import type { MetaDataProps, ProviderType } from "@/types";
|
||||
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
|
||||
@@ -39,7 +17,6 @@ interface ScanListTableProps {
|
||||
scans: AttackPathScan[];
|
||||
}
|
||||
|
||||
const TABLE_COLUMN_COUNT = 6;
|
||||
const DEFAULT_PAGE_SIZE = 5;
|
||||
const PAGE_SIZE_OPTIONS = [2, 5, 10, 15];
|
||||
const WAITING_STATES: readonly ScanState[] = [
|
||||
@@ -48,11 +25,174 @@ const WAITING_STATES: readonly ScanState[] = [
|
||||
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";
|
||||
const parsePageParam = (value: string | null, fallback: number) => {
|
||||
if (!value) return fallback;
|
||||
|
||||
const disabledLinkClass =
|
||||
"text-border-neutral-secondary dark:text-border-neutral-secondary hover:bg-transparent hover:text-border-neutral-secondary dark:hover:text-border-neutral-secondary cursor-default pointer-events-none";
|
||||
const parsedValue = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsedValue) || parsedValue < 1 ? fallback : parsedValue;
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number | null) => {
|
||||
if (!duration) return "-";
|
||||
|
||||
return `${Math.floor(duration / 60)}m ${duration % 60}s`;
|
||||
};
|
||||
|
||||
const isSelectDisabled = (
|
||||
scan: AttackPathScan,
|
||||
selectedScanId: string | null,
|
||||
) => {
|
||||
return (
|
||||
!scan.attributes.graph_data_ready ||
|
||||
scan.attributes.state === SCAN_STATES.FAILED ||
|
||||
selectedScanId === scan.id
|
||||
);
|
||||
};
|
||||
|
||||
const getSelectButtonLabel = (
|
||||
scan: AttackPathScan,
|
||||
selectedScanId: string | null,
|
||||
) => {
|
||||
if (selectedScanId === scan.id) {
|
||||
return "Selected";
|
||||
}
|
||||
|
||||
if (scan.attributes.graph_data_ready) {
|
||||
return "Select";
|
||||
}
|
||||
|
||||
if (WAITING_STATES.includes(scan.attributes.state)) {
|
||||
return "Waiting...";
|
||||
}
|
||||
|
||||
if (scan.attributes.state === SCAN_STATES.FAILED) {
|
||||
return "Failed";
|
||||
}
|
||||
|
||||
return "Select";
|
||||
};
|
||||
|
||||
const getSelectedRowSelection = (
|
||||
scans: AttackPathScan[],
|
||||
selectedScanId: string | null,
|
||||
) => {
|
||||
const selectedIndex = scans.findIndex((scan) => scan.id === selectedScanId);
|
||||
|
||||
if (selectedIndex === -1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { [selectedIndex]: true };
|
||||
};
|
||||
|
||||
const buildMetadata = (
|
||||
totalEntries: number,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): MetaDataProps => ({
|
||||
pagination: {
|
||||
page: currentPage,
|
||||
pages: totalPages,
|
||||
count: totalEntries,
|
||||
itemsPerPage: PAGE_SIZE_OPTIONS,
|
||||
},
|
||||
version: "1",
|
||||
});
|
||||
|
||||
const getColumns = ({
|
||||
selectedScanId,
|
||||
onSelectScan,
|
||||
}: {
|
||||
selectedScanId: string | null;
|
||||
onSelectScan: (scanId: string) => void;
|
||||
}): ColumnDef<AttackPathScan>[] => [
|
||||
{
|
||||
accessorKey: "provider",
|
||||
header: ({ column }) => (
|
||||
<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}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "completed_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Last Scan Date" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.attributes.completed_at ? (
|
||||
<DateWithTime inline dateTime={row.original.attributes.completed_at} />
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ScanStatusBadge
|
||||
status={row.original.attributes.state}
|
||||
progress={row.original.attributes.progress}
|
||||
graphDataReady={row.original.attributes.graph_data_ready}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "progress",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Progress" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">{row.original.attributes.progress}%</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Duration" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{formatDuration(row.original.attributes.duration)}
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => {
|
||||
const isDisabled = isSelectDisabled(row.original, selectedScanId);
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Table displaying AWS account Attack Paths scans
|
||||
@@ -64,290 +204,56 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const selectedScanId = searchParams.get("scanId");
|
||||
const currentPage = parseInt(searchParams.get("scanPage") ?? "1");
|
||||
const pageSize = parseInt(
|
||||
searchParams.get("scanPageSize") ?? String(DEFAULT_PAGE_SIZE),
|
||||
const pageSize = parsePageParam(
|
||||
searchParams.get("scanPageSize"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
);
|
||||
const [selectedPageSize, setSelectedPageSize] = useState(String(pageSize));
|
||||
|
||||
const totalPages = Math.ceil(scans.length / pageSize);
|
||||
const requestedPage = parsePageParam(searchParams.get("scanPage"), 1);
|
||||
const totalPages = Math.max(1, Math.ceil(scans.length / pageSize));
|
||||
const currentPage = Math.min(requestedPage, totalPages);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedScans = scans.slice(startIndex, endIndex);
|
||||
|
||||
const handleSelectScan = (scanId: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("scanId", scanId);
|
||||
const pushWithParams = (nextParams: Record<string, string>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
for (const [key, value] of Object.entries(nextParams)) {
|
||||
params.set(key, value);
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const isSelectDisabled = (scan: AttackPathScan) => {
|
||||
return (
|
||||
!scan.attributes.graph_data_ready ||
|
||||
scan.attributes.state === SCAN_STATES.FAILED ||
|
||||
selectedScanId === scan.id
|
||||
);
|
||||
const handleSelectScan = (scanId: string) => {
|
||||
pushWithParams({ scanId });
|
||||
};
|
||||
|
||||
const getSelectButtonLabel = (scan: AttackPathScan) => {
|
||||
if (selectedScanId === scan.id) {
|
||||
return "Selected";
|
||||
}
|
||||
if (scan.attributes.graph_data_ready) {
|
||||
return "Select";
|
||||
}
|
||||
if (WAITING_STATES.includes(scan.attributes.state)) {
|
||||
return "Waiting...";
|
||||
}
|
||||
if (scan.attributes.state === SCAN_STATES.FAILED) {
|
||||
return "Failed";
|
||||
}
|
||||
return "Select";
|
||||
const handlePageChange = (page: number) => {
|
||||
pushWithParams({ scanPage: page.toString() });
|
||||
};
|
||||
|
||||
const createPageUrl = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Preserve scanId if it exists
|
||||
const scanId = searchParams.get("scanId");
|
||||
|
||||
if (+pageNumber > totalPages) {
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
params.set("scanPage", pageNumber.toString());
|
||||
|
||||
// Ensure that scanId is preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
|
||||
return `${pathname}?${params.toString()}`;
|
||||
const handlePageSizeChange = (nextPageSize: number) => {
|
||||
pushWithParams({
|
||||
scanPage: "1",
|
||||
scanPageSize: nextPageSize.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
const isFirstPage = currentPage === 1;
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<Table aria-label="Attack Paths scans table listing provider accounts, scan dates, status, progress, and duration">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Provider / Account</TableHead>
|
||||
<TableHead>Last Scan Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scans.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={TABLE_COLUMN_COUNT}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No Attack Paths scans available.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedScans.map((scan) => {
|
||||
const isDisabled = isSelectDisabled(scan);
|
||||
const isSelected = selectedScanId === scan.id;
|
||||
const duration = scan.attributes.duration
|
||||
? `${Math.floor(scan.attributes.duration / 60)}m ${scan.attributes.duration % 60}s`
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={scan.id}
|
||||
className={
|
||||
isSelected
|
||||
? "bg-button-primary/10 dark:bg-button-primary/10"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<EntityInfo
|
||||
cloudProvider={
|
||||
scan.attributes.provider_type as ProviderType
|
||||
}
|
||||
entityAlias={scan.attributes.provider_alias}
|
||||
entityId={scan.attributes.provider_uid}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{scan.attributes.completed_at ? (
|
||||
<DateWithTime
|
||||
inline
|
||||
dateTime={scan.attributes.completed_at}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ScanStatusBadge
|
||||
status={scan.attributes.state}
|
||||
progress={scan.attributes.progress}
|
||||
graphDataReady={scan.attributes.graph_data_ready}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{scan.attributes.progress}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{duration}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Select scan"
|
||||
disabled={isDisabled}
|
||||
variant={isDisabled ? "secondary" : "default"}
|
||||
onClick={() => handleSelectScan(scan.id)}
|
||||
className="w-full max-w-24"
|
||||
>
|
||||
{getSelectButtonLabel(scan)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{scans.length > 0 && (
|
||||
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{scans.length} scans in total
|
||||
</div>
|
||||
{scans.length > DEFAULT_PAGE_SIZE && (
|
||||
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
|
||||
{/* Rows per page selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium whitespace-nowrap">
|
||||
Rows per page
|
||||
</p>
|
||||
<Select
|
||||
value={selectedPageSize}
|
||||
onValueChange={(value) => {
|
||||
setSelectedPageSize(value);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Preserve scanId if it exists
|
||||
const scanId = searchParams.get("scanId");
|
||||
|
||||
params.set("scanPageSize", value);
|
||||
params.set("scanPage", "1");
|
||||
|
||||
// Ensure that scanId is preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-18">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem
|
||||
key={size}
|
||||
value={`${size}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
baseLinkClass,
|
||||
isFirstPage && disabledLinkClass,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(1)
|
||||
}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<DoubleArrowLeftIcon
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
baseLinkClass,
|
||||
isFirstPage && disabledLinkClass,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage - 1)
|
||||
}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
baseLinkClass,
|
||||
isLastPage && disabledLinkClass,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage + 1)
|
||||
}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
baseLinkClass,
|
||||
isLastPage && disabledLinkClass,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(totalPages)
|
||||
}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<DoubleArrowRightIcon
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<DataTable
|
||||
columns={getColumns({
|
||||
selectedScanId,
|
||||
onSelectScan: handleSelectScan,
|
||||
})}
|
||||
data={paginatedScans}
|
||||
metadata={buildMetadata(scans.length, currentPage, totalPages)}
|
||||
controlledPage={currentPage}
|
||||
controlledPageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
enableRowSelection
|
||||
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
import { useQueryBuilder } from "./use-query-builder";
|
||||
|
||||
const mockQueries: AttackPathQuery[] = [
|
||||
{
|
||||
type: "attack-paths-scans",
|
||||
id: "query-with-parameters",
|
||||
attributes: {
|
||||
name: "Query With Parameters",
|
||||
short_description: "Requires a principal ARN",
|
||||
description: "Returns paths for a principal",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "principal_arn",
|
||||
label: "Principal ARN",
|
||||
data_type: "string",
|
||||
description: "Principal to analyze",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "attack-paths-scans",
|
||||
id: "query-without-parameters",
|
||||
attributes: {
|
||||
name: "Query Without Parameters",
|
||||
short_description: "Returns all privileged paths",
|
||||
description: "Returns all privileged paths",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("useQueryBuilder", () => {
|
||||
it("drops stale parameter values when switching to a query without parameters", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() => useQueryBuilder(mockQueries));
|
||||
|
||||
act(() => {
|
||||
result.current.handleQueryChange("query-with-parameters");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
"query-with-parameters",
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue(
|
||||
"principal_arn",
|
||||
"arn:aws:iam::123:user/alex",
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.getQueryParameters()).toEqual({
|
||||
principal_arn: "arn:aws:iam::123:user/alex",
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.handleQueryChange("query-without-parameters");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
"query-without-parameters",
|
||||
);
|
||||
expect(result.current.getQueryParameters()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -44,21 +44,21 @@ export const useWizardState = () => {
|
||||
// Derive current step from URL path
|
||||
const currentStep: 1 | 2 =
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname.includes("query-builder")
|
||||
? window.location.pathname.includes("attack-paths")
|
||||
? 2
|
||||
: 1
|
||||
: 1;
|
||||
|
||||
const goToSelectScan = useCallback(() => {
|
||||
store.setCurrentStep(1);
|
||||
router.push("/attack-paths/select-scan");
|
||||
router.push("/attack-paths");
|
||||
}, [router, store]);
|
||||
|
||||
const goToQueryBuilder = useCallback(
|
||||
(scanId: string) => {
|
||||
store.setSelectedScanId(scanId);
|
||||
store.setCurrentStep(2);
|
||||
router.push(`/attack-paths/query-builder?scanId=${scanId}`);
|
||||
router.push(`/attack-paths?scanId=${scanId}`);
|
||||
},
|
||||
[router, store],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import {
|
||||
executeQuery,
|
||||
getAttackPathScans,
|
||||
getAvailableQueries,
|
||||
} from "@/actions/attack-paths";
|
||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/shadcn/dialog";
|
||||
import { useToast } from "@/components/ui";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
AttackPathScan,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
AttackPathGraph,
|
||||
ExecuteButton,
|
||||
GraphControls,
|
||||
GraphLegend,
|
||||
GraphLoading,
|
||||
NodeDetailContent,
|
||||
QueryParametersForm,
|
||||
QuerySelector,
|
||||
ScanListTable,
|
||||
} from "./_components";
|
||||
import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph";
|
||||
import { useGraphState } from "./_hooks/use-graph-state";
|
||||
import { useQueryBuilder } from "./_hooks/use-query-builder";
|
||||
import { exportGraphAsSVG, formatNodeLabel } from "./_lib";
|
||||
|
||||
/**
|
||||
* Attack Paths
|
||||
* Allows users to select a scan, build a query, and visualize the attack path graph
|
||||
*/
|
||||
export default function AttackPathsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const scanId = searchParams.get("scanId");
|
||||
const graphState = useGraphState();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [scansLoading, setScansLoading] = useState(true);
|
||||
const [scans, setScans] = useState<AttackPathScan[]>([]);
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<AttackPathGraphRef>(null);
|
||||
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
|
||||
const hasResetRef = useRef(false);
|
||||
const nodeDetailsRef = useRef<HTMLDivElement>(null);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
|
||||
// Use custom hook for query builder form state and validation
|
||||
const queryBuilder = useQueryBuilder(queries);
|
||||
|
||||
// Reset graph state when component mounts
|
||||
useEffect(() => {
|
||||
if (!hasResetRef.current) {
|
||||
hasResetRef.current = true;
|
||||
graphState.resetGraph();
|
||||
}
|
||||
}, [graphState]);
|
||||
|
||||
// Load available scans on mount
|
||||
useEffect(() => {
|
||||
const loadScans = async () => {
|
||||
setScansLoading(true);
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
setScans(scansData.data);
|
||||
} else {
|
||||
setScans([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load scans:", error);
|
||||
setScans([]);
|
||||
} finally {
|
||||
setScansLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScans();
|
||||
}, []);
|
||||
|
||||
// Check if there's an executing scan for auto-refresh
|
||||
const hasExecutingScan = scans.some(
|
||||
(scan) =>
|
||||
scan.attributes.state === "executing" ||
|
||||
scan.attributes.state === "scheduled",
|
||||
);
|
||||
|
||||
// Callback to refresh scans (used by AutoRefresh component)
|
||||
const refreshScans = async () => {
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
setScans(scansData.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh scans:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Load available queries on mount
|
||||
useEffect(() => {
|
||||
const loadQueries = async () => {
|
||||
if (!scanId) {
|
||||
setQueriesError("No scan selected");
|
||||
setQueriesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setQueriesLoading(true);
|
||||
try {
|
||||
const queriesData = await getAvailableQueries(scanId);
|
||||
if (queriesData?.data) {
|
||||
setQueries(queriesData.data);
|
||||
setQueriesError(null);
|
||||
} else {
|
||||
setQueriesError("Failed to load available queries");
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load queries for this scan",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
setQueriesError(errorMsg);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load queries",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setQueriesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadQueries();
|
||||
}, [scanId, toast]);
|
||||
|
||||
const handleQueryChange = (queryId: string) => {
|
||||
queryBuilder.handleQueryChange(queryId);
|
||||
};
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
variant: "destructive",
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
if (!scanId || !queryBuilder.selectedQuery) {
|
||||
showErrorToast("Error", "Please select both a scan and a query");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate form before executing query
|
||||
const isValid = await queryBuilder.form.trigger();
|
||||
if (!isValid) {
|
||||
showErrorToast(
|
||||
"Validation Error",
|
||||
"Please fill in all required parameters",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
graphState.startLoading();
|
||||
graphState.setError(null);
|
||||
|
||||
try {
|
||||
const parameters = queryBuilder.getQueryParameters() as Record<
|
||||
string,
|
||||
string | number | boolean
|
||||
>;
|
||||
const result = await executeQuery(
|
||||
scanId,
|
||||
queryBuilder.selectedQuery,
|
||||
parameters,
|
||||
);
|
||||
|
||||
if (result && "error" in result) {
|
||||
const apiError = result as AttackPathQueryError;
|
||||
graphState.resetGraph();
|
||||
|
||||
if (apiError.status === 404) {
|
||||
graphState.resetGraph();
|
||||
showErrorToast("No data found", "The query returned no data");
|
||||
} else if (apiError.status === 403) {
|
||||
graphState.setError("Not enough permissions to execute this query");
|
||||
showErrorToast(
|
||||
"Error",
|
||||
"Not enough permissions to execute this query",
|
||||
);
|
||||
} else if (apiError.status >= 500) {
|
||||
const serverDownMessage =
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.";
|
||||
graphState.setError(serverDownMessage);
|
||||
showErrorToast("Error", serverDownMessage);
|
||||
} else {
|
||||
graphState.setError(apiError.error);
|
||||
showErrorToast("Error", apiError.error);
|
||||
}
|
||||
} else if (result?.data?.attributes) {
|
||||
const graphData = adaptQueryResultToGraphData(result.data.attributes);
|
||||
graphState.updateGraphData(graphData);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Query executed successfully",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Scroll to graph after successful query execution
|
||||
setTimeout(() => {
|
||||
graphContainerRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
} else {
|
||||
graphState.resetGraph();
|
||||
graphState.setError("Failed to execute query due to an unknown error");
|
||||
showErrorToast(
|
||||
"Error",
|
||||
"Failed to execute query due to an unknown error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const rawErrorMsg =
|
||||
error instanceof Error ? error.message : "Failed to execute query";
|
||||
const errorMsg = rawErrorMsg.includes("Server Components render")
|
||||
? "Server is temporarily unavailable. Please try again in a few minutes."
|
||||
: rawErrorMsg;
|
||||
graphState.resetGraph();
|
||||
graphState.setError(errorMsg);
|
||||
showErrorToast("Error", errorMsg);
|
||||
} finally {
|
||||
graphState.stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
// Enter filtered view showing only paths containing this node
|
||||
graphState.enterFilteredView(node.id);
|
||||
|
||||
// For findings, also scroll to the details section
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
if (isFinding) {
|
||||
setTimeout(() => {
|
||||
nodeDetailsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToFullView = () => {
|
||||
graphState.exitFilteredView();
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
graphState.selectNode(null);
|
||||
};
|
||||
|
||||
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
|
||||
try {
|
||||
if (svgElement) {
|
||||
exportGraphAsSVG(svgElement, "attack-path-graph.svg");
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Graph exported as SVG",
|
||||
variant: "default",
|
||||
});
|
||||
} else {
|
||||
throw new Error("Could not find graph element");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to export graph",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Auto-refresh scans when there's an executing scan */}
|
||||
<AutoRefresh
|
||||
hasExecutingScan={hasExecutingScan}
|
||||
onRefresh={refreshScans}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="dark:text-prowler-theme-pale/90 text-xl font-semibold">
|
||||
Attack Paths
|
||||
</h2>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-sm">
|
||||
Select a scan, build a query, and visualize Attack Paths in your
|
||||
infrastructure.
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-xs">
|
||||
Scans can be selected when data is available. A new scan does not
|
||||
interrupt access to existing data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scansLoading ? (
|
||||
<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">
|
||||
<p className="text-sm">Loading scans...</p>
|
||||
</div>
|
||||
) : scans.length === 0 ? (
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertTitle>No scans available</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span>
|
||||
You need to run a scan before you can analyze attack paths.{" "}
|
||||
<Link href="/scans" className="font-medium underline">
|
||||
Go to Scan Jobs
|
||||
</Link>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* Scans Table */}
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} />
|
||||
</Suspense>
|
||||
|
||||
{/* 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">
|
||||
{queriesLoading ? (
|
||||
<p className="text-sm">Loading queries...</p>
|
||||
) : queriesError ? (
|
||||
<p className="text-text-danger dark:text-text-danger text-sm">
|
||||
{queriesError}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<FormProvider {...queryBuilder.form}>
|
||||
<QuerySelector
|
||||
queries={queries}
|
||||
selectedQueryId={queryBuilder.selectedQuery}
|
||||
onQueryChange={handleQueryChange}
|
||||
/>
|
||||
|
||||
{queryBuilder.selectedQueryData && (
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md px-3 py-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
style={{ color: "var(--bg-data-info)" }}
|
||||
/>
|
||||
<p className="whitespace-pre-line">
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{queryBuilder.selectedQueryData.attributes
|
||||
.attribution && (
|
||||
<p className="mt-2 text-xs">
|
||||
Source:{" "}
|
||||
<a
|
||||
href={
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.link
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.text
|
||||
}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryBuilder.selectedQuery && (
|
||||
<QueryParametersForm
|
||||
selectedQuery={queryBuilder.selectedQueryData}
|
||||
/>
|
||||
)}
|
||||
</FormProvider>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<ExecuteButton
|
||||
isLoading={graphState.loading}
|
||||
isDisabled={!queryBuilder.selectedQuery}
|
||||
onExecute={handleExecuteQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{graphState.error && (
|
||||
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
|
||||
{graphState.error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph Visualization (Full Width) */}
|
||||
{(graphState.loading ||
|
||||
(graphState.data &&
|
||||
graphState.data.nodes &&
|
||||
graphState.data.nodes.length > 0)) && (
|
||||
<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">
|
||||
{graphState.loading ? (
|
||||
<GraphLoading />
|
||||
) : graphState.data &&
|
||||
graphState.data.nodes &&
|
||||
graphState.data.nodes.length > 0 ? (
|
||||
<>
|
||||
{/* Info message and controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{graphState.isFilteredView ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleBackToFullView}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
aria-label="Return to full graph view"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back to Full View
|
||||
</Button>
|
||||
<div
|
||||
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Filtered view active"
|
||||
>
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
🔍
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Showing paths for:{" "}
|
||||
<strong>
|
||||
{graphState.filteredNode?.properties?.name ||
|
||||
graphState.filteredNode?.properties?.id ||
|
||||
"Selected node"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Graph interaction instructions"
|
||||
>
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
💡
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Click on any node to filter and view its connected
|
||||
paths
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph controls and fullscreen button together */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GraphControls
|
||||
onZoomIn={() => graphRef.current?.zoomIn()}
|
||||
onZoomOut={() => graphRef.current?.zoomOut()}
|
||||
onFitToScreen={() => graphRef.current?.resetZoom()}
|
||||
onExport={() =>
|
||||
handleGraphExport(
|
||||
graphRef.current?.getSVGElement() || null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Fullscreen button */}
|
||||
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
|
||||
<Dialog
|
||||
open={isFullscreenOpen}
|
||||
onOpenChange={setIsFullscreenOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
<Maximize2 size={18} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 rounded-none border-0 p-0 sm:max-w-full">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Fullscreen graph view</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
|
||||
<GraphControls
|
||||
onZoomIn={() =>
|
||||
fullscreenGraphRef.current?.zoomIn()
|
||||
}
|
||||
onZoomOut={() =>
|
||||
fullscreenGraphRef.current?.zoomOut()
|
||||
}
|
||||
onFitToScreen={() =>
|
||||
fullscreenGraphRef.current?.resetZoom()
|
||||
}
|
||||
onExport={() =>
|
||||
handleGraphExport(
|
||||
fullscreenGraphRef.current?.getSVGElement() ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<AttackPathGraph
|
||||
ref={fullscreenGraphRef}
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
{/* Node Detail Panel - Side by side */}
|
||||
{graphState.selectedNode && (
|
||||
<section aria-labelledby="node-details-heading">
|
||||
<Card className="w-96 overflow-y-auto">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3
|
||||
id="node-details-heading"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
Node Details
|
||||
</h3>
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
|
||||
{graphState.selectedNode?.labels.some(
|
||||
(label) =>
|
||||
label
|
||||
.toLowerCase()
|
||||
.includes("finding"),
|
||||
)
|
||||
? graphState.selectedNode?.properties
|
||||
?.check_title ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Finding"
|
||||
: graphState.selectedNode?.properties
|
||||
?.name ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Resource"}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold">
|
||||
Type
|
||||
</h4>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
|
||||
{graphState.selectedNode?.labels
|
||||
.map(formatNodeLabel)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph in the middle */}
|
||||
<div
|
||||
ref={graphContainerRef}
|
||||
className="h-[calc(100vh-22rem)]"
|
||||
>
|
||||
<AttackPathGraph
|
||||
ref={graphRef}
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legend below */}
|
||||
<div className="hidden justify-center lg:flex">
|
||||
<GraphLegend data={graphState.data} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Detail Panel - Below Graph */}
|
||||
{graphState.selectedNode && graphState.data && (
|
||||
<div
|
||||
ref={nodeDetailsRef}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Node Details</h3>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
|
||||
{String(
|
||||
graphState.selectedNode.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
)
|
||||
? graphState.selectedNode.properties?.check_title ||
|
||||
graphState.selectedNode.properties?.id ||
|
||||
"Unknown Finding"
|
||||
: graphState.selectedNode.properties?.name ||
|
||||
graphState.selectedNode.properties?.id ||
|
||||
"Unknown Resource",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{graphState.selectedNode.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
) && (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<a
|
||||
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||
>
|
||||
View Finding →
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NodeDetailContent
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,712 +1,31 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import {
|
||||
executeQuery,
|
||||
getAttackPathScans,
|
||||
getAvailableQueries,
|
||||
} from "@/actions/attack-paths";
|
||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
useToast,
|
||||
} from "@/components/ui";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
AttackPathScan,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
const buildQueryString = (searchParams: SearchParamsProps) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
import {
|
||||
AttackPathGraph,
|
||||
ExecuteButton,
|
||||
GraphControls,
|
||||
GraphLegend,
|
||||
GraphLoading,
|
||||
NodeDetailContent,
|
||||
QueryParametersForm,
|
||||
QuerySelector,
|
||||
ScanListTable,
|
||||
} from "./_components";
|
||||
import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph";
|
||||
import { useGraphState } from "./_hooks/use-graph-state";
|
||||
import { useQueryBuilder } from "./_hooks/use-query-builder";
|
||||
import { exportGraphAsSVG, formatNodeLabel } from "./_lib";
|
||||
|
||||
/**
|
||||
* Attack Paths Analysis
|
||||
* Allows users to select a scan, build a query, and visualize the Attack Paths graph
|
||||
*/
|
||||
export default function AttackPathAnalysisPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const scanId = searchParams.get("scanId");
|
||||
const graphState = useGraphState();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [scansLoading, setScansLoading] = useState(true);
|
||||
const [scans, setScans] = useState<AttackPathScan[]>([]);
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<AttackPathGraphRef>(null);
|
||||
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
|
||||
const hasResetRef = useRef(false);
|
||||
const nodeDetailsRef = useRef<HTMLDivElement>(null);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
|
||||
// Use custom hook for query builder form state and validation
|
||||
const queryBuilder = useQueryBuilder(queries);
|
||||
|
||||
// Reset graph state when component mounts
|
||||
useEffect(() => {
|
||||
if (!hasResetRef.current) {
|
||||
hasResetRef.current = true;
|
||||
graphState.resetGraph();
|
||||
}
|
||||
}, [graphState]);
|
||||
|
||||
// Load available scans on mount
|
||||
useEffect(() => {
|
||||
const loadScans = async () => {
|
||||
setScansLoading(true);
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
setScans(scansData.data);
|
||||
} else {
|
||||
setScans([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load scans:", error);
|
||||
setScans([]);
|
||||
} finally {
|
||||
setScansLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScans();
|
||||
}, []);
|
||||
|
||||
// Check if there's an executing scan for auto-refresh
|
||||
const hasExecutingScan = scans.some(
|
||||
(scan) =>
|
||||
scan.attributes.state === "executing" ||
|
||||
scan.attributes.state === "scheduled",
|
||||
);
|
||||
|
||||
// Callback to refresh scans (used by AutoRefresh component)
|
||||
const refreshScans = useCallback(async () => {
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
setScans(scansData.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh scans:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load available queries on mount
|
||||
useEffect(() => {
|
||||
const loadQueries = async () => {
|
||||
if (!scanId) {
|
||||
setQueriesError("No scan selected");
|
||||
setQueriesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setQueriesLoading(true);
|
||||
try {
|
||||
const queriesData = await getAvailableQueries(scanId);
|
||||
if (queriesData?.data) {
|
||||
setQueries(queriesData.data);
|
||||
setQueriesError(null);
|
||||
} else {
|
||||
setQueriesError("Failed to load available queries");
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load queries for this scan",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
setQueriesError(errorMsg);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load queries",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setQueriesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadQueries();
|
||||
}, [scanId, toast]);
|
||||
|
||||
const handleQueryChange = (queryId: string) => {
|
||||
queryBuilder.handleQueryChange(queryId);
|
||||
};
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
variant: "destructive",
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
if (!scanId || !queryBuilder.selectedQuery) {
|
||||
showErrorToast("Error", "Please select both a scan and a query");
|
||||
return;
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => params.append(key, item));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate form before executing query
|
||||
const isValid = await queryBuilder.form.trigger();
|
||||
if (!isValid) {
|
||||
showErrorToast(
|
||||
"Validation Error",
|
||||
"Please fill in all required parameters",
|
||||
);
|
||||
return;
|
||||
if (typeof value === "string") {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
graphState.startLoading();
|
||||
graphState.setError(null);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
try {
|
||||
const parameters = queryBuilder.getQueryParameters() as Record<
|
||||
string,
|
||||
string | number | boolean
|
||||
>;
|
||||
const result = await executeQuery(
|
||||
scanId,
|
||||
queryBuilder.selectedQuery,
|
||||
parameters,
|
||||
);
|
||||
export default async function AttackPathsQueryBuilderRedirectPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const queryString = buildQueryString(resolvedSearchParams);
|
||||
|
||||
if (result && "error" in result) {
|
||||
const apiError = result as AttackPathQueryError;
|
||||
graphState.resetGraph();
|
||||
|
||||
if (apiError.status === 404) {
|
||||
graphState.setError("No data found");
|
||||
showErrorToast("No data found", "The query returned no data");
|
||||
} else if (apiError.status === 403) {
|
||||
graphState.setError("Not enough permissions to execute this query");
|
||||
showErrorToast(
|
||||
"Error",
|
||||
"Not enough permissions to execute this query",
|
||||
);
|
||||
} else if (apiError.status >= 500) {
|
||||
const serverDownMessage =
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.";
|
||||
graphState.setError(serverDownMessage);
|
||||
showErrorToast("Error", serverDownMessage);
|
||||
} else {
|
||||
graphState.setError(apiError.error);
|
||||
showErrorToast("Error", apiError.error);
|
||||
}
|
||||
} else if (result?.data?.attributes) {
|
||||
const graphData = adaptQueryResultToGraphData(result.data.attributes);
|
||||
graphState.updateGraphData(graphData);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Query executed successfully",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Scroll to graph after successful query execution
|
||||
setTimeout(() => {
|
||||
graphContainerRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
} else {
|
||||
graphState.resetGraph();
|
||||
graphState.setError("Failed to execute query due to an unknown error");
|
||||
showErrorToast(
|
||||
"Error",
|
||||
"Failed to execute query due to an unknown error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const rawErrorMsg =
|
||||
error instanceof Error ? error.message : "Failed to execute query";
|
||||
const errorMsg = rawErrorMsg.includes("Server Components render")
|
||||
? "Server is temporarily unavailable. Please try again in a few minutes."
|
||||
: rawErrorMsg;
|
||||
graphState.resetGraph();
|
||||
graphState.setError(errorMsg);
|
||||
showErrorToast("Error", errorMsg);
|
||||
} finally {
|
||||
graphState.stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
// Enter filtered view showing only paths containing this node
|
||||
graphState.enterFilteredView(node.id);
|
||||
|
||||
// For findings, also scroll to the details section
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
if (isFinding) {
|
||||
setTimeout(() => {
|
||||
nodeDetailsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToFullView = () => {
|
||||
graphState.exitFilteredView();
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
graphState.selectNode(null);
|
||||
};
|
||||
|
||||
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
|
||||
try {
|
||||
if (svgElement) {
|
||||
exportGraphAsSVG(svgElement, "attack-path-graph.svg");
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Graph exported as SVG",
|
||||
variant: "default",
|
||||
});
|
||||
} else {
|
||||
throw new Error("Could not find graph element");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to export graph",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Auto-refresh scans when there's an executing scan */}
|
||||
<AutoRefresh
|
||||
hasExecutingScan={hasExecutingScan}
|
||||
onRefresh={refreshScans}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="dark:text-prowler-theme-pale/90 text-xl font-semibold">
|
||||
Attack Paths Analysis
|
||||
</h2>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-sm">
|
||||
Select a scan, build a query, and visualize Attack Paths in your
|
||||
infrastructure.
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-xs">
|
||||
Scans can be selected when data is available. A new scan does not
|
||||
interrupt access to existing data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scansLoading ? (
|
||||
<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">
|
||||
<p className="text-sm">Loading scans...</p>
|
||||
</div>
|
||||
) : scans.length === 0 ? (
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertTitle>No scans available</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span>
|
||||
You need to run a scan before you can analyze attack paths.{" "}
|
||||
<Link href="/scans" className="font-medium underline">
|
||||
Go to Scan Jobs
|
||||
</Link>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* Scans Table */}
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} />
|
||||
</Suspense>
|
||||
|
||||
{/* 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">
|
||||
{queriesLoading ? (
|
||||
<p className="text-sm">Loading queries...</p>
|
||||
) : queriesError ? (
|
||||
<p className="text-text-danger dark:text-text-danger text-sm">
|
||||
{queriesError}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<FormProvider {...queryBuilder.form}>
|
||||
<QuerySelector
|
||||
queries={queries}
|
||||
selectedQueryId={queryBuilder.selectedQuery}
|
||||
onQueryChange={handleQueryChange}
|
||||
/>
|
||||
|
||||
{queryBuilder.selectedQueryData && (
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md p-3 text-sm">
|
||||
<p className="whitespace-pre-line">
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.description
|
||||
}
|
||||
</p>
|
||||
{queryBuilder.selectedQueryData.attributes
|
||||
.attribution && (
|
||||
<p className="mt-2 text-xs">
|
||||
Source:{" "}
|
||||
<a
|
||||
href={
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.link
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.text
|
||||
}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryBuilder.selectedQuery && (
|
||||
<QueryParametersForm
|
||||
selectedQuery={queryBuilder.selectedQueryData}
|
||||
/>
|
||||
)}
|
||||
</FormProvider>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<ExecuteButton
|
||||
isLoading={graphState.loading}
|
||||
isDisabled={!queryBuilder.selectedQuery}
|
||||
onExecute={handleExecuteQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{graphState.error && (
|
||||
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
|
||||
{graphState.error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph Visualization (Full Width) */}
|
||||
{(graphState.loading ||
|
||||
(graphState.data &&
|
||||
graphState.data.nodes &&
|
||||
graphState.data.nodes.length > 0)) && (
|
||||
<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">
|
||||
{graphState.loading ? (
|
||||
<GraphLoading />
|
||||
) : graphState.data &&
|
||||
graphState.data.nodes &&
|
||||
graphState.data.nodes.length > 0 ? (
|
||||
<>
|
||||
{/* Info message and controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{graphState.isFilteredView ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleBackToFullView}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
aria-label="Return to full graph view"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back to Full View
|
||||
</Button>
|
||||
<div
|
||||
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Filtered view active"
|
||||
>
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
🔍
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Showing paths for:{" "}
|
||||
<strong>
|
||||
{graphState.filteredNode?.properties?.name ||
|
||||
graphState.filteredNode?.properties?.id ||
|
||||
"Selected node"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Graph interaction instructions"
|
||||
>
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
💡
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Click on any node to filter and view its connected
|
||||
paths
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph controls and fullscreen button together */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GraphControls
|
||||
onZoomIn={() => graphRef.current?.zoomIn()}
|
||||
onZoomOut={() => graphRef.current?.zoomOut()}
|
||||
onFitToScreen={() => graphRef.current?.resetZoom()}
|
||||
onExport={() =>
|
||||
handleGraphExport(
|
||||
graphRef.current?.getSVGElement() || null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Fullscreen button */}
|
||||
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
|
||||
<Dialog
|
||||
open={isFullscreenOpen}
|
||||
onOpenChange={setIsFullscreenOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
<Maximize2 size={18} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 p-0">
|
||||
<DialogHeader className="px-4 pt-4 sm:px-6 sm:pt-6">
|
||||
<DialogTitle className="text-lg">
|
||||
Graph Fullscreen View
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
|
||||
<GraphControls
|
||||
onZoomIn={() =>
|
||||
fullscreenGraphRef.current?.zoomIn()
|
||||
}
|
||||
onZoomOut={() =>
|
||||
fullscreenGraphRef.current?.zoomOut()
|
||||
}
|
||||
onFitToScreen={() =>
|
||||
fullscreenGraphRef.current?.resetZoom()
|
||||
}
|
||||
onExport={() =>
|
||||
handleGraphExport(
|
||||
fullscreenGraphRef.current?.getSVGElement() ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<AttackPathGraph
|
||||
ref={fullscreenGraphRef}
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
{/* Node Detail Panel - Side by side */}
|
||||
{graphState.selectedNode && (
|
||||
<section aria-labelledby="node-details-heading">
|
||||
<Card className="w-96 overflow-y-auto">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3
|
||||
id="node-details-heading"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
Node Details
|
||||
</h3>
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
|
||||
{graphState.selectedNode?.labels.some(
|
||||
(label) =>
|
||||
label
|
||||
.toLowerCase()
|
||||
.includes("finding"),
|
||||
)
|
||||
? graphState.selectedNode?.properties
|
||||
?.check_title ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Finding"
|
||||
: graphState.selectedNode?.properties
|
||||
?.name ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Resource"}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold">
|
||||
Type
|
||||
</h4>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
|
||||
{graphState.selectedNode?.labels
|
||||
.map(formatNodeLabel)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph in the middle */}
|
||||
<div
|
||||
ref={graphContainerRef}
|
||||
className="h-[calc(100vh-22rem)]"
|
||||
>
|
||||
<AttackPathGraph
|
||||
ref={graphRef}
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legend below */}
|
||||
<div className="hidden justify-center lg:flex">
|
||||
<GraphLegend data={graphState.data} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Detail Panel - Below Graph */}
|
||||
{graphState.selectedNode && graphState.data && (
|
||||
<div
|
||||
ref={nodeDetailsRef}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Node Details</h3>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
|
||||
{String(
|
||||
graphState.selectedNode.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
)
|
||||
? graphState.selectedNode.properties?.check_title ||
|
||||
graphState.selectedNode.properties?.id ||
|
||||
"Unknown Finding"
|
||||
: graphState.selectedNode.properties?.name ||
|
||||
graphState.selectedNode.properties?.id ||
|
||||
"Unknown Resource",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{graphState.selectedNode.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
) && (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<a
|
||||
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||
>
|
||||
View Finding →
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NodeDetailContent
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
redirect(queryString ? `/attack-paths?${queryString}` : "/attack-paths");
|
||||
}
|
||||
|
||||
13
ui/app/(prowler)/attack-paths/layout.tsx
Normal file
13
ui/app/(prowler)/attack-paths/layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
|
||||
export default function AttackPathsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ContentLayout title="Attack Paths" icon="lucide:git-branch">
|
||||
{children}
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Landing page for Attack Paths feature
|
||||
* Redirects to the integrated attack path analysis view
|
||||
*/
|
||||
export default function AttackPathsPage() {
|
||||
redirect("/attack-paths/query-builder");
|
||||
}
|
||||
export { default } from "./(workflow)/query-builder/attack-paths-page";
|
||||
|
||||
@@ -71,9 +71,11 @@ const ProvidersTableFallback = () => {
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* ProviderTypeSelector */}
|
||||
<Skeleton className="h-[52px] min-w-[200px] flex-1 rounded-lg md:max-w-[280px]" />
|
||||
{/* Account filter */}
|
||||
{/* Organizations filter */}
|
||||
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
|
||||
{/* Connection Status filter */}
|
||||
{/* Account Groups filter */}
|
||||
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
|
||||
{/* Status filter */}
|
||||
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
|
||||
{/* Action buttons */}
|
||||
<div className="ml-auto flex flex-wrap gap-4">
|
||||
|
||||
@@ -20,24 +20,23 @@ interface ComplianceScanInfoProps {
|
||||
|
||||
export const ComplianceScanInfo = ({ scan }: ComplianceScanInfoProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="flex min-w-0 basis-1/2 items-center overflow-hidden">
|
||||
<EntityInfo
|
||||
cloudProvider={scan.providerInfo.provider}
|
||||
entityAlias={scan.providerInfo.alias}
|
||||
entityId={scan.providerInfo.uid}
|
||||
showCopyAction={false}
|
||||
maxWidth="w-[80px]"
|
||||
/>
|
||||
</div>
|
||||
<Divider orientation="vertical" className="h-8" />
|
||||
<div className="flex flex-col items-start whitespace-nowrap">
|
||||
<Divider orientation="vertical" className="h-8 shrink-0" />
|
||||
<div className="flex min-w-0 basis-1/2 flex-col items-start overflow-hidden">
|
||||
<Tooltip
|
||||
content={scan.attributes.name || "- -"}
|
||||
placement="top"
|
||||
size="sm"
|
||||
>
|
||||
<p className="text-default-500 text-xs">
|
||||
<p className="text-default-500 truncate text-xs">
|
||||
{scan.attributes.name || "- -"}
|
||||
</p>
|
||||
</Tooltip>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const ScanSelector = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-[365px]">
|
||||
<SelectTrigger className="w-full max-w-[360px]">
|
||||
<SelectValue placeholder="Select a scan">
|
||||
{selectedScan ? (
|
||||
<ComplianceScanInfo scan={selectedScan} />
|
||||
@@ -48,9 +48,13 @@ export const ScanSelector = ({
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-w-[360px]">
|
||||
{scans.map((scan) => (
|
||||
<SelectItem key={scan.id} value={scan.id}>
|
||||
<SelectItem
|
||||
key={scan.id}
|
||||
value={scan.id}
|
||||
className="data-[state=checked]:bg-bg-neutral-tertiary"
|
||||
>
|
||||
<ComplianceScanInfo scan={scan} />
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,6 @@ export * from "./credentials-update-info";
|
||||
export * from "./forms/delete-form";
|
||||
export * from "./link-to-scans";
|
||||
export * from "./muted-findings-config-button";
|
||||
export * from "./provider-info";
|
||||
export * from "./providers-accounts-table";
|
||||
export * from "./providers-filters";
|
||||
export * from "./radio-card";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
import { getProviderLogo } from "../ui/entities";
|
||||
|
||||
interface ProviderInfoProps {
|
||||
provider: ProviderType;
|
||||
providerAlias: string | null;
|
||||
providerUID?: string;
|
||||
}
|
||||
|
||||
export const ProviderInfo = ({
|
||||
provider,
|
||||
providerAlias,
|
||||
providerUID,
|
||||
}: ProviderInfoProps) => {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center text-sm">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="shrink-0">{getProviderLogo(provider)}</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">
|
||||
{providerAlias || providerUID}
|
||||
</span>
|
||||
{providerUID && (
|
||||
<span className="text-text-neutral-tertiary truncate text-xs">
|
||||
UID: {providerUID}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,62 +9,33 @@ import {
|
||||
ShieldOff,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { DataTableExpandAllToggle } from "@/components/ui/table/data-table-expand-all-toggle";
|
||||
import { DataTableExpandableCell } from "@/components/ui/table/data-table-expandable-cell";
|
||||
import {
|
||||
isProvidersOrganizationRow,
|
||||
PROVIDERS_GROUP_KIND,
|
||||
ProvidersOrganizationRow,
|
||||
ProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
|
||||
import { LinkToScans } from "../link-to-scans";
|
||||
import { ProviderInfo } from "../provider-info";
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
|
||||
interface GroupNameChipsProps {
|
||||
groupNames?: string[];
|
||||
}
|
||||
|
||||
interface OrganizationCellProps {
|
||||
organization: ProvidersOrganizationRow;
|
||||
selectionLabel?: string;
|
||||
}
|
||||
|
||||
const OrganizationCell = ({
|
||||
organization,
|
||||
selectionLabel,
|
||||
}: OrganizationCellProps) => {
|
||||
const OrganizationIcon = ({ groupKind }: { groupKind: string }) => {
|
||||
const Icon =
|
||||
organization.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION
|
||||
? Building2
|
||||
: FolderTree;
|
||||
groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION ? Building2 : FolderTree;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-primary flex size-9 shrink-0 items-center justify-center rounded-xl">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate font-medium">{organization.name}</span>
|
||||
{selectionLabel && (
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs">
|
||||
({selectionLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{organization.externalId && (
|
||||
<span className="text-text-neutral-tertiary truncate text-xs">
|
||||
UID: {organization.externalId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-primary flex size-9 items-center justify-center rounded-xl">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -191,9 +162,12 @@ export function getColumnProviders(
|
||||
hideChildIcon
|
||||
checkboxSlot={checkboxSlot}
|
||||
>
|
||||
<OrganizationCell
|
||||
organization={row.original}
|
||||
selectionLabel={getSelectionLabel(row)}
|
||||
<EntityInfo
|
||||
icon={<OrganizationIcon groupKind={row.original.groupKind} />}
|
||||
entityAlias={row.original.name}
|
||||
entityId={row.original.externalId ?? undefined}
|
||||
badge={getSelectionLabel(row)}
|
||||
showCopyAction
|
||||
/>
|
||||
</DataTableExpandableCell>
|
||||
);
|
||||
@@ -207,10 +181,10 @@ export function getColumnProviders(
|
||||
isExpanded={isExpanded}
|
||||
checkboxSlot={checkboxSlot}
|
||||
>
|
||||
<ProviderInfo
|
||||
provider={provider.attributes.provider}
|
||||
providerAlias={provider.attributes.alias}
|
||||
providerUID={provider.attributes.uid}
|
||||
<EntityInfo
|
||||
cloudProvider={provider.attributes.provider}
|
||||
entityAlias={provider.attributes.alias}
|
||||
entityId={provider.attributes.uid}
|
||||
/>
|
||||
</DataTableExpandableCell>
|
||||
);
|
||||
@@ -364,9 +338,7 @@ export function GroupNameChips({ groupNames }: GroupNameChipsProps) {
|
||||
return (
|
||||
<div className="flex max-w-[260px] flex-wrap gap-1">
|
||||
{groupNames.map((name, index) => (
|
||||
<Badge key={index} variant="tag">
|
||||
{name}
|
||||
</Badge>
|
||||
<CodeSnippet key={index} value={name} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ export function BreadcrumbNavigation({
|
||||
};
|
||||
|
||||
const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{typeof icon === "string" ? (
|
||||
<Icon
|
||||
className="text-text-neutral-primary"
|
||||
@@ -125,7 +125,7 @@ export function BreadcrumbNavigation({
|
||||
>
|
||||
{titleText}
|
||||
</h1>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Determine which breadcrumbs to use
|
||||
|
||||
@@ -80,7 +80,7 @@ export const CodeSnippet = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary flex h-6 w-fit items-center gap-2 rounded-lg border px-2 py-1 text-xs",
|
||||
"bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary flex h-6 w-fit min-w-0 items-center gap-1.5 rounded-full border-2 px-2 py-0.5 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -11,73 +13,67 @@ import type { ProviderType } from "@/types";
|
||||
import { getProviderLogo } from "./get-provider-logo";
|
||||
|
||||
interface EntityInfoProps {
|
||||
cloudProvider: ProviderType;
|
||||
cloudProvider?: ProviderType;
|
||||
icon?: ReactNode;
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
snippetWidth?: string;
|
||||
showConnectionStatus?: boolean;
|
||||
maxWidth?: string;
|
||||
badge?: string;
|
||||
showCopyAction?: boolean;
|
||||
/** @deprecated No longer used — layout handles overflow naturally */
|
||||
maxWidth?: string;
|
||||
/** @deprecated No longer used */
|
||||
showConnectionStatus?: boolean;
|
||||
/** @deprecated No longer used */
|
||||
snippetWidth?: string;
|
||||
}
|
||||
|
||||
export const EntityInfo = ({
|
||||
cloudProvider,
|
||||
icon,
|
||||
entityAlias,
|
||||
entityId,
|
||||
showConnectionStatus = false,
|
||||
maxWidth = "w-[120px]",
|
||||
badge,
|
||||
showCopyAction = true,
|
||||
}: EntityInfoProps) => {
|
||||
const canCopy = Boolean(entityId && showCopyAction);
|
||||
const renderedIcon =
|
||||
icon ?? (cloudProvider ? getProviderLogo(cloudProvider) : null);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
{getProviderLogo(cloudProvider)}
|
||||
{showConnectionStatus && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full bg-green-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Connected</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex ${maxWidth} flex-col gap-1`}>
|
||||
{entityAlias ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-text-neutral-primary truncate text-left text-xs font-medium">
|
||||
{entityAlias}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{entityAlias}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-text-neutral-secondary truncate text-left text-xs">
|
||||
-
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">No alias</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{entityId && (
|
||||
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary flex w-full min-w-0 items-center gap-1 rounded-xl border px-1.5">
|
||||
<div className="flex min-w-0 items-center text-sm">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{renderedIcon && <div className="shrink-0">{renderedIcon}</div>}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-text-neutral-secondary min-w-0 flex-1 truncate text-left text-xs">
|
||||
{entityId}
|
||||
</p>
|
||||
<span className="truncate font-medium">
|
||||
{entityAlias || entityId || "-"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{entityId}</TooltipContent>
|
||||
<TooltipContent side="top">
|
||||
{entityAlias || entityId || "No alias"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canCopy && (
|
||||
<CodeSnippet value={entityId} hideCode className="shrink-0" />
|
||||
{badge && (
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs">
|
||||
({badge})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{entityId && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
|
||||
UID:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={entityId}
|
||||
className="max-w-[160px]"
|
||||
hideCopyButton={!canCopy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user