refactor(ui): attack paths restyling and component migrations (#10310)

This commit is contained in:
Alejandro Bailo
2026-03-12 13:49:34 +01:00
committed by GitHub
parent 97a91bfaaa
commit 63e10c9661
22 changed files with 1386 additions and 1177 deletions

View File

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

View File

@@ -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>
</>
);
}

View File

@@ -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",
);
});
});

View File

@@ -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();
});
});

View File

@@ -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) => (

View File

@@ -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",
);
});
});

View File

@@ -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)}
/>
);
};

View File

@@ -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();
});
});

View File

@@ -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],
);

View File

@@ -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>
);
}

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

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

View File

@@ -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,
)}
>

View File

@@ -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>
);