mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
fix(ui): improve attack paths graph interactions
- Restore supported graph scroll zoom behavior - Add node action selector for ambiguous resource clicks - Open finding and node details in existing drawers - Cover resource actions with browser tests
This commit is contained in:
+66
-43
@@ -53,11 +53,7 @@ interface AttackPathGraphProps {
|
||||
selectedNodeId?: string | null;
|
||||
isFilteredView?: boolean;
|
||||
initialNodeId?: string;
|
||||
// Tier 1 expansion state — controlled by the parent (lives in the graph
|
||||
// store) so it survives filtered-view enter/exit. If omitted, no resource
|
||||
// expansion is tracked and findings stay hidden in the full view.
|
||||
expandedResources?: Set<string>;
|
||||
onResourceToggle?: (resourceId: string) => void;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
|
||||
ref?: Ref<GraphHandle>;
|
||||
@@ -182,7 +178,6 @@ const GraphCanvas = ({
|
||||
isFilteredView,
|
||||
initialNodeId,
|
||||
expandedResources,
|
||||
onResourceToggle,
|
||||
onNodeClick,
|
||||
onInitialFilter,
|
||||
ref,
|
||||
@@ -272,23 +267,40 @@ const GraphCanvas = ({
|
||||
}
|
||||
if (previousFilteredRef.current === isFilteredView) return;
|
||||
previousFilteredRef.current = isFilteredView;
|
||||
// Defer to the next animation frame so React Flow has applied the new
|
||||
// layout (after the filter swap) before we measure for the fit.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
fitView(AUTO_FIT_OPTIONS);
|
||||
});
|
||||
// React Flow measures node sizes asynchronously via ResizeObserver after
|
||||
// the data swap. A single rAF runs while measured.width is still 0, so
|
||||
// fitView computes a degenerate bbox and the viewport keeps the user's
|
||||
// previous zoom — most visibly when leaving a filtered view in which the
|
||||
// user had zoomed in. Poll until visible nodes are measured (or give up
|
||||
// after ~500ms so we never block on a stuck observer).
|
||||
let frame = 0;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 30;
|
||||
const tryFit = () => {
|
||||
const visibleNodes = getNodes().filter((n) => !n.hidden);
|
||||
const allMeasured =
|
||||
visibleNodes.length > 0 &&
|
||||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0);
|
||||
if (allMeasured || attempts >= MAX_ATTEMPTS) {
|
||||
fitView(AUTO_FIT_OPTIONS);
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
frame = requestAnimationFrame(tryFit);
|
||||
};
|
||||
frame = requestAnimationFrame(tryFit);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isFilteredView, fitView]);
|
||||
}, [isFilteredView, fitView, getNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousExpandedRef.current;
|
||||
previousExpandedRef.current = expanded;
|
||||
if (previous === expanded) return;
|
||||
// Only fit on growth — collapsing intentionally leaves the user's
|
||||
// framing alone.
|
||||
const newResourceIds = Array.from(expanded).filter(
|
||||
(id) => !previous.has(id),
|
||||
);
|
||||
// Only fit on growth — collapsing intentionally leaves the user's
|
||||
// current framing alone.
|
||||
if (newResourceIds.length === 0) return;
|
||||
|
||||
const newFindingIds = new Set<string>();
|
||||
@@ -299,26 +311,42 @@ const GraphCanvas = ({
|
||||
}
|
||||
if (newFindingIds.size === 0) return;
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
const { width, height } = containerEl.getBoundingClientRect();
|
||||
if (width === 0 || height === 0) return;
|
||||
const { x, y, zoom } = getViewport();
|
||||
const minX = -x / zoom;
|
||||
const minY = -y / zoom;
|
||||
const maxX = minX + width / zoom;
|
||||
const maxY = minY + height / zoom;
|
||||
const anyOutside = getNodes().some((node) => {
|
||||
if (!newFindingIds.has(node.id)) return false;
|
||||
const nx = node.position.x;
|
||||
const ny = node.position.y;
|
||||
const nw = node.measured?.width ?? 0;
|
||||
const nh = node.measured?.height ?? 0;
|
||||
return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY;
|
||||
});
|
||||
if (anyOutside) fitView(AUTO_FIT_OPTIONS);
|
||||
});
|
||||
// Findings transition from hidden to visible on expand, and React Flow
|
||||
// measures them asynchronously. Poll before checking whether their full
|
||||
// bounding boxes sit entirely past a viewport edge; collapsing and
|
||||
// partially clipped findings preserve the user's current frame.
|
||||
let frame = 0;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 30;
|
||||
const tryFit = () => {
|
||||
const targets = getNodes().filter((n) => newFindingIds.has(n.id));
|
||||
const allMeasured =
|
||||
targets.length === newFindingIds.size &&
|
||||
targets.every((n) => (n.measured?.width ?? 0) > 0);
|
||||
if (allMeasured || attempts >= MAX_ATTEMPTS) {
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
const { width, height } = containerEl.getBoundingClientRect();
|
||||
if (width === 0 || height === 0) return;
|
||||
const { x, y, zoom } = getViewport();
|
||||
const minX = -x / zoom;
|
||||
const minY = -y / zoom;
|
||||
const maxX = minX + width / zoom;
|
||||
const maxY = minY + height / zoom;
|
||||
const anyOutside = targets.some((node) => {
|
||||
const nx = node.position.x;
|
||||
const ny = node.position.y;
|
||||
const nw = node.measured?.width ?? 0;
|
||||
const nh = node.measured?.height ?? 0;
|
||||
return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY;
|
||||
});
|
||||
if (anyOutside) fitView(AUTO_FIT_OPTIONS);
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
frame = requestAnimationFrame(tryFit);
|
||||
};
|
||||
frame = requestAnimationFrame(tryFit);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [expanded, fitView, getNodes, getViewport]);
|
||||
|
||||
@@ -434,13 +462,6 @@ const GraphCanvas = ({
|
||||
const handleNodeClick = (_event: MouseEvent, node: Node) => {
|
||||
const graphNode = (node.data as { graphNode: GraphNode }).graphNode;
|
||||
|
||||
// Tier 1: clicking resource in full view toggles connected findings
|
||||
if (!isFilteredView && !isFindingNode(graphNode.labels)) {
|
||||
if (resourcesWithFindings.has(node.id)) {
|
||||
onResourceToggle?.(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Always fire parent callback (handles selection + Tier 2 filtered view)
|
||||
onNodeClick?.(graphNode);
|
||||
};
|
||||
@@ -465,10 +486,14 @@ const GraphCanvas = ({
|
||||
onNodeMouseLeave={handleNodeMouseLeave}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, includeHiddenNodes: true }}
|
||||
zoomOnScroll={false}
|
||||
// Supported React Flow behavior: wheel over the graph zooms the
|
||||
// viewport. The surrounding UX avoids using node details as inline
|
||||
// content, so this no longer fights a below-graph details section.
|
||||
zoomOnScroll={true}
|
||||
zoomOnPinch={true}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={false}
|
||||
preventScrolling={true}
|
||||
minZoom={0.1}
|
||||
maxZoom={10}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
@@ -508,7 +533,6 @@ export const AttackPathGraph = ({
|
||||
isFilteredView,
|
||||
initialNodeId,
|
||||
expandedResources,
|
||||
onResourceToggle,
|
||||
onNodeClick,
|
||||
onInitialFilter,
|
||||
ref,
|
||||
@@ -533,7 +557,6 @@ export const AttackPathGraph = ({
|
||||
isFilteredView={isFilteredView}
|
||||
initialNodeId={initialNodeId}
|
||||
expandedResources={expandedResources}
|
||||
onResourceToggle={onResourceToggle}
|
||||
onNodeClick={onNodeClick}
|
||||
onInitialFilter={onInitialFilter}
|
||||
/>
|
||||
|
||||
+106
@@ -212,6 +212,104 @@ describe("exploring the graph", () => {
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("clicking a resource with findings opens the action selector", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("choosing Show findings reveals related finding nodes", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("expanded resources offer Hide findings in the action selector", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.containsText(/Hide findings/i)).toBe(true);
|
||||
|
||||
await graph.chooseHideFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test("choosing View node details opens node details in a modal", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseViewNodeDetailsAction();
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking a resource without findings opens node details in a modal", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
|
||||
await graph.clickFirstResourceNodeWithoutFindings();
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking a parent node in filtered view asks whether to go back or view details", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.containsText(/Back to full graph/i)).toBe(true);
|
||||
|
||||
await graph.chooseBackToFullGraphAction();
|
||||
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
});
|
||||
|
||||
test("exiting the filtered view restores the full graph", async ({
|
||||
@@ -294,6 +392,14 @@ describe("auto-fitting the viewport", () => {
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
// Given - zoom into the current overview so newly revealed findings can
|
||||
// sit entirely outside the current frame. The expand auto-fit should then
|
||||
// recover the user instead of leaving them hunting off-screen.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await graph.zoomIn();
|
||||
await graph.waitForTransition(80);
|
||||
}
|
||||
|
||||
// Hidden findings are not measured by the initial declarative fit, so
|
||||
// their positions can sit outside the framed viewport. Expanding the
|
||||
// resources should re-fit so the user does not have to hunt for the
|
||||
|
||||
@@ -231,6 +231,23 @@ export class AttackPathPageHarness {
|
||||
return document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
}
|
||||
|
||||
get nodeDetailsHeading(): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[role='dialog'] h2"),
|
||||
).find((heading) => /^Node Details$/i.test(heading.textContent ?? "")) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
get hasNodeDetailsModal(): boolean {
|
||||
return !!this.nodeDetailsHeading;
|
||||
}
|
||||
|
||||
get hasNodeActionDialog(): boolean {
|
||||
return this.containsText(/Choose node action/i);
|
||||
}
|
||||
|
||||
// --- Sync helpers ---
|
||||
|
||||
/** Wait until React Flow has rendered at least `expected` node elements. */
|
||||
@@ -341,6 +358,33 @@ export class AttackPathPageHarness {
|
||||
return resource;
|
||||
}
|
||||
|
||||
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
|
||||
const findingIds = new Set(
|
||||
(this.fixture.queryResult?.nodes ?? [])
|
||||
.filter((n) =>
|
||||
n.labels.some((l) => l.toLowerCase().includes("finding")),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const resourceWithFindingIds = new Set<string>();
|
||||
for (const rel of this.fixture.queryResult?.relationships ?? []) {
|
||||
if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target);
|
||||
if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source);
|
||||
}
|
||||
const resource = this.resourceNodes.find((node) => {
|
||||
const id = node.getAttribute("data-id");
|
||||
return id && !resourceWithFindingIds.has(id);
|
||||
});
|
||||
if (!resource) {
|
||||
throw new Error(
|
||||
"clickFirstResourceNodeWithoutFindings: no resource without findings rendered",
|
||||
);
|
||||
}
|
||||
await this.user.click(resource);
|
||||
await this.waitForTransition();
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the first finding `times` times back-to-back, with no transition
|
||||
* waits between clicks. Used for rapid-click race tests.
|
||||
@@ -399,6 +443,9 @@ export class AttackPathPageHarness {
|
||||
if (el) {
|
||||
await this.user.click(el);
|
||||
await this.waitForTransition(50);
|
||||
if (this.hasNodeActionDialog) {
|
||||
await this.chooseShowFindingsAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,6 +491,51 @@ export class AttackPathPageHarness {
|
||||
await this.user.click(btn);
|
||||
}
|
||||
|
||||
async closeNodeDetailsModal(): Promise<void> {
|
||||
const btn = this.q('button[aria-label="Close node details"]');
|
||||
if (!btn) throw new Error("closeNodeDetailsModal: modal not rendered");
|
||||
await this.user.click(btn);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseShowFindingsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /show findings/i.test(btn.textContent ?? ""));
|
||||
if (!button) throw new Error("chooseShowFindingsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseHideFindingsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /hide findings/i.test(btn.textContent ?? ""));
|
||||
if (!button) throw new Error("chooseHideFindingsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseViewNodeDetailsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /view node details/i.test(btn.textContent ?? ""));
|
||||
if (!button)
|
||||
throw new Error("chooseViewNodeDetailsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseBackToFullGraphAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /back to full graph/i.test(btn.textContent ?? ""));
|
||||
if (!button)
|
||||
throw new Error("chooseBackToFullGraphAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async exitFilteredView(): Promise<void> {
|
||||
const btn = this.toolbar.backToFullViewButton;
|
||||
if (!btn) throw new Error("exitFilteredView: not in filtered view");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
|
||||
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -33,9 +31,8 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/shadcn/dialog";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { Modal } from "@/components/shadcn/modal/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
@@ -50,7 +47,7 @@ import {
|
||||
GraphControls,
|
||||
GraphLegend,
|
||||
GraphLoading,
|
||||
NodeDetailContent,
|
||||
NodeDetailPanel as NodeDetailDrawer,
|
||||
QueryDescription,
|
||||
QueryExecutionError,
|
||||
QueryParametersForm,
|
||||
@@ -62,98 +59,13 @@ import { useGraphState } from "./_hooks/use-graph-state";
|
||||
import { useQueryBuilder } from "./_hooks/use-query-builder";
|
||||
import { exportGraphAsPNG } from "./_lib";
|
||||
|
||||
const getNodeDisplayTitle = (node: GraphNode): string => {
|
||||
const isFinding = node.labels.some((l) =>
|
||||
l.toLowerCase().includes("finding"),
|
||||
);
|
||||
return String(
|
||||
isFinding
|
||||
? node.properties?.check_title || node.properties?.id || "Unknown Finding"
|
||||
: node.properties?.name || node.properties?.id || "Unknown Resource",
|
||||
);
|
||||
};
|
||||
const NODE_ACTION_CONTEXT = {
|
||||
RESOURCE_FINDINGS: "resource-findings",
|
||||
FILTERED_PARENT: "filtered-parent",
|
||||
} as const;
|
||||
|
||||
interface NodeDetailPanelProps {
|
||||
node: GraphNode;
|
||||
allNodes: GraphNode[];
|
||||
onClose: () => void;
|
||||
headingId: string;
|
||||
compact?: boolean;
|
||||
onViewFinding?: (findingId: string) => void;
|
||||
viewFindingLoading?: boolean;
|
||||
}
|
||||
|
||||
const NodeDetailPanel = ({
|
||||
node,
|
||||
allNodes,
|
||||
onClose,
|
||||
headingId,
|
||||
compact,
|
||||
onViewFinding,
|
||||
viewFindingLoading = false,
|
||||
}: NodeDetailPanelProps) => {
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
const findingId = String(node.properties?.id || node.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
id={headingId}
|
||||
className={
|
||||
compact ? "text-sm font-semibold" : "text-lg font-semibold"
|
||||
}
|
||||
>
|
||||
Node Details
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-text-neutral-secondary",
|
||||
compact ? "mb-4 text-xs" : "mt-1 text-sm",
|
||||
)}
|
||||
>
|
||||
{getNodeDisplayTitle(node)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!compact && isFinding && onViewFinding && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onViewFinding(findingId)}
|
||||
disabled={viewFindingLoading}
|
||||
aria-label={`View finding ${findingId}`}
|
||||
>
|
||||
{viewFindingLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
"View Finding"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={compact ? "h-6 w-6 p-0" : "h-8 w-8 p-0"}
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeDetailContent
|
||||
node={node}
|
||||
allNodes={allNodes}
|
||||
onViewFinding={onViewFinding}
|
||||
viewFindingLoading={viewFindingLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
type NodeActionContext =
|
||||
(typeof NODE_ACTION_CONTEXT)[keyof typeof NODE_ACTION_CONTEXT];
|
||||
|
||||
/**
|
||||
* Attack Paths
|
||||
@@ -171,10 +83,13 @@ export default function AttackPathsPage() {
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const [actionNode, setActionNode] = useState<GraphNode | null>(null);
|
||||
const [actionContext, setActionContext] = useState<NodeActionContext>(
|
||||
NODE_ACTION_CONTEXT.RESOURCE_FINDINGS,
|
||||
);
|
||||
const graphRef = useRef<GraphHandle>(null);
|
||||
const fullscreenGraphRef = useRef<GraphHandle>(null);
|
||||
const hasResetRef = useRef(false);
|
||||
const nodeDetailsRef = useRef<HTMLDivElement>(null);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
@@ -385,27 +300,46 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
// Always select the node (opens detail panel)
|
||||
graphState.selectNode(node.id);
|
||||
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
// Tier 2: clicking a finding node OR any node in filtered view → enter filtered view
|
||||
if (isFinding || graphState.isFilteredView) {
|
||||
if (isFinding) {
|
||||
// Findings skip the intermediate node-details modal. The finding drawer
|
||||
// is the useful destination, so open it directly from the graph click.
|
||||
graphState.enterFilteredView(node.id);
|
||||
// enterFilteredView stores the filtered node as selected so the graph can
|
||||
// highlight it. Clear the selection right after for findings so the node
|
||||
// details modal does not open before the finding drawer.
|
||||
graphState.selectNode(null);
|
||||
handleViewFinding(String(node.properties?.id || node.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll to details section for findings
|
||||
if (isFinding) {
|
||||
setTimeout(() => {
|
||||
nodeDetailsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}, 100);
|
||||
if (graphState.isFilteredView) {
|
||||
setActionNode(node);
|
||||
setActionContext(NODE_ACTION_CONTEXT.FILTERED_PARENT);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceData = graphState.fullData || graphState.data;
|
||||
const hasFindings = sourceData?.edges?.some((edge) => {
|
||||
if (edge.source !== node.id && edge.target !== node.id) return false;
|
||||
const otherId = edge.source === node.id ? edge.target : edge.source;
|
||||
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
|
||||
return otherNode?.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
});
|
||||
|
||||
if (hasFindings) {
|
||||
setActionNode(node);
|
||||
setActionContext(NODE_ACTION_CONTEXT.RESOURCE_FINDINGS);
|
||||
return;
|
||||
}
|
||||
|
||||
finding.resetFindingDetails();
|
||||
graphState.selectNode(node.id);
|
||||
};
|
||||
|
||||
const handleBackToFullView = () => {
|
||||
@@ -416,11 +350,34 @@ export default function AttackPathsPage() {
|
||||
graphState.selectNode(null);
|
||||
};
|
||||
|
||||
const handleShowNodeFindings = () => {
|
||||
if (!actionNode) return;
|
||||
graphState.toggleExpandedResource(actionNode.id);
|
||||
setActionNode(null);
|
||||
};
|
||||
|
||||
const handleOpenNodeDetails = () => {
|
||||
if (!actionNode) return;
|
||||
finding.resetFindingDetails();
|
||||
graphState.selectNode(actionNode.id);
|
||||
setActionNode(null);
|
||||
};
|
||||
|
||||
const handleReturnToFullGraph = () => {
|
||||
graphState.exitFilteredView();
|
||||
graphState.selectNode(null);
|
||||
setActionNode(null);
|
||||
};
|
||||
|
||||
const handleViewFinding = (findingId: string) => {
|
||||
if (!findingId) return;
|
||||
void finding.navigateToFinding(findingId);
|
||||
};
|
||||
|
||||
const actionNodeFindingsExpanded = actionNode
|
||||
? graphState.expandedResources.has(actionNode.id)
|
||||
: false;
|
||||
|
||||
const handleGraphExport = async (target: "main" | "fullscreen") => {
|
||||
const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef;
|
||||
const handle = ref.current;
|
||||
@@ -674,34 +631,8 @@ export default function AttackPathsPage() {
|
||||
expandedResources={
|
||||
graphState.expandedResources
|
||||
}
|
||||
onResourceToggle={
|
||||
graphState.toggleExpandedResource
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Node Detail Panel - Side by side */}
|
||||
{graphState.selectedNode && graphState.data && (
|
||||
<section
|
||||
aria-labelledby="fullscreen-node-details-heading"
|
||||
className="w-full overflow-y-auto lg:w-96"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<NodeDetailPanel
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onClose={handleCloseDetails}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={
|
||||
finding.findingDetailLoading
|
||||
}
|
||||
headingId="fullscreen-node-details-heading"
|
||||
compact
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -721,7 +652,6 @@ export default function AttackPathsPage() {
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
expandedResources={graphState.expandedResources}
|
||||
onResourceToggle={graphState.toggleExpandedResource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -734,21 +664,48 @@ export default function AttackPathsPage() {
|
||||
</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"
|
||||
{/* Node Detail Drawer */}
|
||||
{graphState.data && (
|
||||
<NodeDetailDrawer
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onClose={handleCloseDetails}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={finding.findingDetailLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{actionNode && (
|
||||
<Modal
|
||||
open={!!actionNode}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActionNode(null);
|
||||
}}
|
||||
size="md"
|
||||
title="Choose node action"
|
||||
description={
|
||||
actionContext === NODE_ACTION_CONTEXT.FILTERED_PARENT
|
||||
? "You're viewing a filtered path. Choose whether to return to the full graph or inspect this node."
|
||||
: "This node has related findings. Choose whether to reveal them in the graph or inspect the node metadata."
|
||||
}
|
||||
>
|
||||
<NodeDetailPanel
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onClose={handleCloseDetails}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={finding.findingDetailLoading}
|
||||
headingId="node-details-heading"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={handleOpenNodeDetails}>
|
||||
View node details
|
||||
</Button>
|
||||
{actionContext === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (
|
||||
<Button onClick={handleReturnToFullGraph}>
|
||||
Back to full graph
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleShowNodeFindings}>
|
||||
{actionNodeFindingsExpanded
|
||||
? "Hide findings"
|
||||
: "Show findings"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{finding.findingDetails && (
|
||||
|
||||
Reference in New Issue
Block a user