mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 12:24:17 +00:00
682 lines
22 KiB
TypeScript
682 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import "@xyflow/react/dist/style.css";
|
||
|
||
import {
|
||
Background,
|
||
MiniMap,
|
||
type Node,
|
||
ReactFlow,
|
||
ReactFlowProvider,
|
||
type Rect,
|
||
useReactFlow,
|
||
} from "@xyflow/react";
|
||
import { useTheme } from "next-themes";
|
||
import {
|
||
type MouseEvent,
|
||
type Ref,
|
||
useEffect,
|
||
useImperativeHandle,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
|
||
import { FindingNode } from "@/components/attack-paths/graph/nodes/finding-node";
|
||
import { GroupNode } from "@/components/attack-paths/graph/nodes/group-node";
|
||
import { InternetNode } from "@/components/attack-paths/graph/nodes/internet-node";
|
||
import { OutcomeNode } from "@/components/attack-paths/graph/nodes/outcome-node";
|
||
import { ResourceNode } from "@/components/attack-paths/graph/nodes/resource-node";
|
||
import {
|
||
computeFilteredSubgraph,
|
||
getNodeBorderColor,
|
||
getNodeColor,
|
||
getPathEdges,
|
||
GRAPH_EDGE_HIGHLIGHT_COLOR,
|
||
isProwlerFindingNode,
|
||
resolveHiddenFindingIds,
|
||
} from "@/lib/attack-paths";
|
||
import {
|
||
type AttackPathView,
|
||
buildAttackPathView,
|
||
} from "@/lib/attack-paths/group-graph";
|
||
import { layoutWithDagre, NODE_TYPE } from "@/lib/attack-paths/layout";
|
||
import { isCloud } from "@/lib/shared/env";
|
||
import { cn } from "@/lib/utils";
|
||
import type {
|
||
AttackPathGraphData,
|
||
AttackPathOutcome,
|
||
GraphNode,
|
||
} from "@/types/attack-paths";
|
||
|
||
// --- Types ---
|
||
|
||
export interface GraphHandle {
|
||
zoomIn: () => void;
|
||
zoomOut: () => void;
|
||
resetZoom: () => void;
|
||
getZoomLevel: () => number;
|
||
getContainerElement: () => HTMLDivElement | null;
|
||
getNodesBounds: () => Rect | null;
|
||
}
|
||
|
||
interface AttackPathGraphProps {
|
||
data: AttackPathGraphData;
|
||
selectedNodeId?: string | null;
|
||
isFilteredView?: boolean;
|
||
initialNodeId?: string;
|
||
expandedResources?: Set<string>;
|
||
// Which resource-class groups are expanded to their members.
|
||
expandedClasses?: Set<string>;
|
||
// Terminal outcome for the current query; injected as the graph's end node.
|
||
outcome?: AttackPathOutcome | null;
|
||
// Pre-built attack-path view shared with the PNG export so both render the
|
||
// same grouped/outcome graph. Falls back to an internal build when omitted.
|
||
view?: AttackPathView | null;
|
||
onNodeClick?: (node: GraphNode) => void;
|
||
onNodeDoubleClick?: (node: GraphNode) => void;
|
||
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
|
||
ref?: Ref<GraphHandle>;
|
||
className?: string;
|
||
}
|
||
|
||
const EMPTY_EXPANDED: ReadonlySet<string> = new Set();
|
||
|
||
// --- Node type registry (stable reference) ---
|
||
|
||
const NODE_TYPES = {
|
||
[NODE_TYPE.FINDING]: FindingNode,
|
||
[NODE_TYPE.INTERNET]: InternetNode,
|
||
[NODE_TYPE.RESOURCE]: ResourceNode,
|
||
[NODE_TYPE.GROUP]: GroupNode,
|
||
[NODE_TYPE.OUTCOME]: OutcomeNode,
|
||
} as const;
|
||
|
||
// --- CSS for animated dashed edges, selected node pulse, and edge highlight ---
|
||
|
||
const GRAPH_STYLES = `
|
||
@keyframes dash {
|
||
to { stroke-dashoffset: -20; }
|
||
}
|
||
.react-flow .finding-edge .react-flow__edge-path {
|
||
stroke-dasharray: 8 6;
|
||
animation: dash 1s linear infinite;
|
||
}
|
||
@keyframes selectedPulse {
|
||
0%, 100% { stroke-opacity: 1; }
|
||
50% { stroke-opacity: 0.6; }
|
||
}
|
||
.selected-node {
|
||
animation: selectedPulse 1.2s ease-in-out infinite;
|
||
}
|
||
.react-flow .highlighted .react-flow__edge-path {
|
||
stroke: ${GRAPH_EDGE_HIGHLIGHT_COLOR};
|
||
stroke-width: 3;
|
||
filter: drop-shadow(0 0 4px ${GRAPH_EDGE_HIGHLIGHT_COLOR});
|
||
}
|
||
`;
|
||
|
||
// --- SVG filter color constants ---
|
||
|
||
const GRAPH_FINDING_GLOW_COLOR = "#ef4444";
|
||
const GRAPH_SELECTED_GLOW_COLOR = GRAPH_EDGE_HIGHLIGHT_COLOR;
|
||
|
||
// --- SVG filter defs (shared by all node components) ---
|
||
|
||
const GraphDefs = () => (
|
||
<svg width={0} height={0} className="absolute" aria-hidden="true">
|
||
<defs>
|
||
{/* Glow filter for finding nodes */}
|
||
<filter id="glow">
|
||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||
<feMerge>
|
||
<feMergeNode in="coloredBlur" />
|
||
<feMergeNode in="SourceGraphic" />
|
||
</feMerge>
|
||
</filter>
|
||
{/* Red glow for resources with findings */}
|
||
<filter id="redGlow">
|
||
<feDropShadow
|
||
dx="0"
|
||
dy="0"
|
||
stdDeviation="4"
|
||
floodColor={GRAPH_FINDING_GLOW_COLOR}
|
||
floodOpacity="0.6"
|
||
/>
|
||
</filter>
|
||
{/* Prowler green glow for selected nodes */}
|
||
<filter id="selectedGlow">
|
||
<feDropShadow
|
||
dx="0"
|
||
dy="0"
|
||
stdDeviation="6"
|
||
floodColor={GRAPH_SELECTED_GLOW_COLOR}
|
||
floodOpacity="0.8"
|
||
/>
|
||
</filter>
|
||
</defs>
|
||
</svg>
|
||
);
|
||
|
||
// --- Inner component: calls useReactFlow(), owns layout derivation ---
|
||
|
||
type GraphCanvasProps = Omit<AttackPathGraphProps, "className">;
|
||
|
||
// Mask covers the area NOT currently in view; the cut-out rectangle is the
|
||
// viewport. To make the viewport rectangle stand out we darken the mask and
|
||
// give its border high contrast against the minimap background.
|
||
const MINIMAP_COLORS = {
|
||
light: {
|
||
bg: "#f8fafc",
|
||
mask: "rgba(15, 23, 42, 0.45)",
|
||
maskStroke: "#0f172a",
|
||
},
|
||
dark: {
|
||
bg: "#0f172a",
|
||
mask: "rgba(0, 0, 0, 0.7)",
|
||
maskStroke: "#cbd5e1",
|
||
},
|
||
} as const;
|
||
|
||
const MINIMAP_VIEWPORT_STROKE_WIDTH = 3;
|
||
|
||
// Animated re-fit shared by every auto-fit trigger. We deliberately do not
|
||
// cap maxZoom — for small subgraphs (e.g. a finding's filtered view with 3
|
||
// nodes) the user expects the result to fill the canvas; an artificial cap
|
||
// looks like a layout error.
|
||
//
|
||
// Padding is asymmetric: the minimap sits in the bottom-right corner of the
|
||
// canvas (default 200×150 panel + offset), so a fit with uniform padding
|
||
// can drop nodes underneath it. Generous bottom/right padding keeps the
|
||
// fitted graph clear of the minimap.
|
||
const AUTO_FIT_OPTIONS = {
|
||
padding: { top: "32px", left: "32px", right: "240px", bottom: "180px" },
|
||
duration: 300,
|
||
} as const;
|
||
|
||
const MEASURED_FIT_MAX_ATTEMPTS = 30;
|
||
|
||
const scheduleMeasuredFit = (
|
||
isMeasured: () => boolean,
|
||
onMeasured: () => void,
|
||
) => {
|
||
let frame = 0;
|
||
let attempts = 0;
|
||
|
||
const tryFit = () => {
|
||
if (isMeasured() || attempts >= MEASURED_FIT_MAX_ATTEMPTS) {
|
||
onMeasured();
|
||
return;
|
||
}
|
||
|
||
attempts += 1;
|
||
frame = requestAnimationFrame(tryFit);
|
||
};
|
||
|
||
frame = requestAnimationFrame(tryFit);
|
||
|
||
return () => cancelAnimationFrame(frame);
|
||
};
|
||
|
||
const GraphCanvas = ({
|
||
data,
|
||
selectedNodeId,
|
||
isFilteredView = false,
|
||
initialNodeId,
|
||
expandedResources,
|
||
expandedClasses,
|
||
outcome,
|
||
view,
|
||
onNodeClick,
|
||
onNodeDoubleClick,
|
||
onInitialFilter,
|
||
ref,
|
||
}: GraphCanvasProps) => {
|
||
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
|
||
useReactFlow();
|
||
const { resolvedTheme } = useTheme();
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const hasInitialized = useRef(false);
|
||
|
||
const minimapColors =
|
||
resolvedTheme === "dark" ? MINIMAP_COLORS.dark : MINIMAP_COLORS.light;
|
||
|
||
// Path highlight state — local to the canvas, must reset on data swaps
|
||
// (otherwise we'd keep highlighting an edge that no longer exists).
|
||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||
useEffect(() => {
|
||
setHoveredNodeId(null);
|
||
}, [data]);
|
||
|
||
// Tier 1 expansion is controlled by the parent (zustand store) so it
|
||
// survives the data-swaps that happen on filtered-view enter/exit.
|
||
const expanded = expandedResources ?? EMPTY_EXPANDED;
|
||
|
||
// Re-frame when a class group is expanded/collapsed so the new member set
|
||
// fits smoothly instead of overflowing the viewport. Newly revealed members
|
||
// are measured asynchronously, so poll until they have real dimensions rather
|
||
// than fitting on a single frame where measured.width can still be 0.
|
||
useEffect(() => {
|
||
return scheduleMeasuredFit(
|
||
() => {
|
||
const visibleNodes = getNodes().filter((n) => !n.hidden);
|
||
return (
|
||
visibleNodes.length > 0 &&
|
||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||
);
|
||
},
|
||
() => fitView(AUTO_FIT_OPTIONS),
|
||
);
|
||
}, [expandedClasses, fitView, getNodes]);
|
||
|
||
// --- initialNodeId: synchronous filtered-view derivation on first render ---
|
||
// Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
|
||
let effectiveData = data;
|
||
if (
|
||
initialNodeId &&
|
||
!hasInitialized.current &&
|
||
data.nodes.some((n) => n.id === initialNodeId)
|
||
) {
|
||
effectiveData = computeFilteredSubgraph(data, initialNodeId);
|
||
}
|
||
|
||
// Sync store flags via useLayoutEffect (runs before paint)
|
||
useLayoutEffect(() => {
|
||
if (hasInitialized.current) return;
|
||
hasInitialized.current = true;
|
||
if (
|
||
initialNodeId &&
|
||
data.nodes.some((n) => n.id === initialNodeId) &&
|
||
onInitialFilter
|
||
) {
|
||
onInitialFilter(effectiveData);
|
||
}
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- one-time init
|
||
|
||
// --- Auto-fit triggers ---
|
||
//
|
||
// Filter toggles (finding click → filtered view, "Back to Full View" →
|
||
// full graph) swap the data prop, which leaves the previous viewport
|
||
// pointing at coordinates that no longer contain the new layout. Re-fit
|
||
// once React Flow has applied the new layout (next animation frame).
|
||
//
|
||
// Resource expansion re-fits after newly revealed finding nodes have been
|
||
// measured. This restores the original Show findings behavior: expanding a
|
||
// resource should frame the selected resource plus its findings, not the
|
||
// entire graph. Collapsing re-fits the remaining visible graph so the user
|
||
// does not stay zoomed into empty space after hiding finding children.
|
||
const filteredFitInitRef = useRef(false);
|
||
const previousFilteredRef = useRef(isFilteredView);
|
||
const previousExpandedRef = useRef<ReadonlySet<string>>(expanded);
|
||
// Captured every render so the expand-fit effect can resolve a resource
|
||
// ID to its connected finding IDs without recomputing the lookup.
|
||
// The map itself is built later in this render — see assignment below
|
||
// the layout-derivation block.
|
||
const resourceToFindingsRef = useRef<Map<string, Set<string>>>(new Map());
|
||
|
||
useEffect(() => {
|
||
if (!filteredFitInitRef.current) {
|
||
filteredFitInitRef.current = true;
|
||
previousFilteredRef.current = isFilteredView;
|
||
return;
|
||
}
|
||
if (previousFilteredRef.current === isFilteredView) return;
|
||
const wasFilteredView = previousFilteredRef.current;
|
||
previousFilteredRef.current = isFilteredView;
|
||
// 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).
|
||
return scheduleMeasuredFit(
|
||
() => {
|
||
const visibleNodes = getNodes().filter((n) => !n.hidden);
|
||
return (
|
||
visibleNodes.length > 0 &&
|
||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||
);
|
||
},
|
||
() => {
|
||
if (wasFilteredView && !isFilteredView && expanded.size > 0) {
|
||
const contextualNodeIds = new Set<string>();
|
||
for (const resourceId of Array.from(expanded)) {
|
||
contextualNodeIds.add(resourceId);
|
||
resourceToFindingsRef.current
|
||
.get(resourceId)
|
||
?.forEach((findingId) => contextualNodeIds.add(findingId));
|
||
}
|
||
|
||
const contextualNodes = getNodes().filter(
|
||
(node) => !node.hidden && contextualNodeIds.has(node.id),
|
||
);
|
||
|
||
if (contextualNodes.length > 0) {
|
||
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
|
||
return;
|
||
}
|
||
}
|
||
|
||
fitView(AUTO_FIT_OPTIONS);
|
||
},
|
||
);
|
||
}, [expanded, isFilteredView, fitView, getNodes]);
|
||
|
||
useEffect(() => {
|
||
const previous = previousExpandedRef.current;
|
||
previousExpandedRef.current = expanded;
|
||
if (previous === expanded) return;
|
||
const newResourceIds = Array.from(expanded).filter(
|
||
(id) => !previous.has(id),
|
||
);
|
||
const collapsedResourceIds = Array.from(previous).filter(
|
||
(id) => !expanded.has(id),
|
||
);
|
||
|
||
if (newResourceIds.length === 0) {
|
||
if (collapsedResourceIds.length === 0) return;
|
||
|
||
return scheduleMeasuredFit(
|
||
() => {
|
||
const visibleNodes = getNodes().filter((n) => !n.hidden);
|
||
return (
|
||
visibleNodes.length > 0 &&
|
||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||
);
|
||
},
|
||
() => fitView(AUTO_FIT_OPTIONS),
|
||
);
|
||
}
|
||
|
||
const contextualFitNodeIds = new Set<string>(newResourceIds);
|
||
const newFindingIds = new Set<string>();
|
||
for (const resourceId of newResourceIds) {
|
||
const findings = resourceToFindingsRef.current.get(resourceId);
|
||
if (!findings) continue;
|
||
findings.forEach((id) => {
|
||
newFindingIds.add(id);
|
||
contextualFitNodeIds.add(id);
|
||
});
|
||
}
|
||
if (newFindingIds.size === 0) return;
|
||
|
||
// Findings transition from hidden to visible on expand, and React Flow
|
||
// measures them asynchronously. Poll before fitting so fitView uses real
|
||
// dimensions instead of the zero-size hidden-node measurements.
|
||
return scheduleMeasuredFit(
|
||
() => {
|
||
const targets = getNodes().filter((n) => newFindingIds.has(n.id));
|
||
return (
|
||
targets.length === newFindingIds.size &&
|
||
targets.every((n) => (n.measured?.width ?? 0) > 0)
|
||
);
|
||
},
|
||
() => {
|
||
const contextualNodes = getNodes().filter((n) =>
|
||
contextualFitNodeIds.has(n.id),
|
||
);
|
||
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
|
||
},
|
||
);
|
||
}, [expanded, fitView, getNodes]);
|
||
|
||
// Cloud reshapes into the attack-path view: drop the account hub, collapse
|
||
// each resource class into one expandable node, and inject the outcome node.
|
||
// OSS keeps the original flat graph, so the grouped/outcome view is Cloud-only.
|
||
// Findings pass through either way; the finding-hide logic below reveals them
|
||
// per-resource, preserving existing behaviour.
|
||
//
|
||
// The parent builds and shares this view with the PNG export so both stay in
|
||
// sync; only fall back to an internal build when it is not provided.
|
||
const resolvedView =
|
||
view ??
|
||
(isCloud()
|
||
? buildAttackPathView({
|
||
data: effectiveData,
|
||
expandedClasses: expandedClasses ?? EMPTY_EXPANDED,
|
||
outcome,
|
||
})
|
||
: { nodes: effectiveData.nodes ?? [], edges: effectiveData.edges ?? [] });
|
||
const nodes = resolvedView.nodes;
|
||
const edges = resolvedView.edges;
|
||
|
||
// Pre-compute which resources have findings connected (O(n+e))
|
||
const findingNodeIds = new Set<string>();
|
||
const resourceToFindings = new Map<string, Set<string>>();
|
||
const findingToResources = new Map<string, Set<string>>();
|
||
|
||
nodes.forEach((n) => {
|
||
if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id);
|
||
});
|
||
|
||
const resourcesWithFindings = new Set<string>();
|
||
edges.forEach((edge) => {
|
||
const sourceIsFinding = findingNodeIds.has(edge.source);
|
||
const targetIsFinding = findingNodeIds.has(edge.target);
|
||
|
||
if (sourceIsFinding) {
|
||
resourcesWithFindings.add(edge.target);
|
||
// Map resource → its findings
|
||
const findings = resourceToFindings.get(edge.target) ?? new Set();
|
||
findings.add(edge.source);
|
||
resourceToFindings.set(edge.target, findings);
|
||
// Map finding → its resources
|
||
const resources = findingToResources.get(edge.source) ?? new Set();
|
||
resources.add(edge.target);
|
||
findingToResources.set(edge.source, resources);
|
||
}
|
||
if (targetIsFinding) {
|
||
resourcesWithFindings.add(edge.source);
|
||
const findings = resourceToFindings.get(edge.source) ?? new Set();
|
||
findings.add(edge.target);
|
||
resourceToFindings.set(edge.source, findings);
|
||
const resources = findingToResources.get(edge.target) ?? new Set();
|
||
resources.add(edge.source);
|
||
findingToResources.set(edge.target, resources);
|
||
}
|
||
});
|
||
|
||
// Refresh the expand-fit effect's lookup with the latest mapping.
|
||
resourceToFindingsRef.current = resourceToFindings;
|
||
|
||
// Tier 1: compute which finding nodes are hidden (not expanded)
|
||
const hiddenFindingIds = resolveHiddenFindingIds({
|
||
expandedResources: expanded,
|
||
findingNodeIds,
|
||
findingToResources,
|
||
isFilteredView,
|
||
});
|
||
|
||
const layoutNodeIds = new Set(
|
||
nodes
|
||
.filter((node) => !hiddenFindingIds.has(node.id))
|
||
.map((node) => node.id),
|
||
);
|
||
const layoutNodes = nodes.filter((node) => layoutNodeIds.has(node.id));
|
||
const layoutEdges = edges.filter(
|
||
(edge) => layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target),
|
||
);
|
||
|
||
// Derive RF nodes and edges from visible data only. Hidden finding nodes are
|
||
// excluded before Dagre runs so the initial tier-1 graph does not reserve
|
||
// empty space for findings that the user has not expanded yet.
|
||
const { rfNodes, rfEdges } = layoutWithDagre(layoutNodes, layoutEdges);
|
||
|
||
// Path highlight: compute highlighted edge IDs
|
||
const activeHighlightNodeId = hoveredNodeId ?? selectedNodeId;
|
||
const highlightedEdgeIds = activeHighlightNodeId
|
||
? getPathEdges(
|
||
activeHighlightNodeId,
|
||
rfEdges.map((e) => ({ sourceId: e.source, targetId: e.target })),
|
||
)
|
||
: new Set<string>();
|
||
|
||
// Enrich nodes with selection, hasFindings, and hidden state
|
||
const enrichedNodes = rfNodes.map((node) => ({
|
||
...node,
|
||
selected: node.id === selectedNodeId,
|
||
hidden: hiddenFindingIds.has(node.id),
|
||
className: cn(
|
||
node.className,
|
||
isProwlerFindingNode(node.data.graphNode.labels) ||
|
||
resourcesWithFindings.has(node.id)
|
||
? "cursor-pointer"
|
||
: "cursor-default",
|
||
),
|
||
data: {
|
||
...node.data,
|
||
hasFindings: resourcesWithFindings.has(node.id),
|
||
},
|
||
}));
|
||
|
||
// Enrich edges with hidden state (hide edges to hidden findings) and highlight
|
||
const enrichedEdges = rfEdges.map((edge) => {
|
||
const sourceHidden = hiddenFindingIds.has(edge.source);
|
||
const targetHidden = hiddenFindingIds.has(edge.target);
|
||
const pathKey =
|
||
typeof edge.data === "object" && edge.data && "pathKey" in edge.data
|
||
? String((edge.data as { pathKey?: string }).pathKey)
|
||
: `${edge.source}-${edge.target}`;
|
||
const isHighlighted =
|
||
highlightedEdgeIds.has(edge.id) || highlightedEdgeIds.has(pathKey);
|
||
|
||
return {
|
||
...edge,
|
||
hidden: sourceHidden || targetHidden,
|
||
className: cn(edge.className, isHighlighted && "highlighted"),
|
||
};
|
||
});
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
zoomIn: () => zoomIn({ duration: 300 }),
|
||
zoomOut: () => zoomOut({ duration: 300 }),
|
||
resetZoom: () => fitView({ duration: 300 }),
|
||
getZoomLevel: () => getZoom(),
|
||
getContainerElement: () => containerRef.current,
|
||
getNodesBounds: () => {
|
||
const rfNodes = getNodes();
|
||
if (rfNodes.length === 0) return null;
|
||
return getNodesBounds(rfNodes);
|
||
},
|
||
}));
|
||
|
||
const handleNodeClick = (_event: MouseEvent, node: Node) => {
|
||
const graphNode = (node.data as { graphNode: GraphNode }).graphNode;
|
||
|
||
// Always fire parent callback (handles selection + Tier 2 filtered view)
|
||
onNodeClick?.(graphNode);
|
||
};
|
||
|
||
const handleNodeDoubleClick = (_event: MouseEvent, node: Node) => {
|
||
const graphNode = (node.data as { graphNode: GraphNode }).graphNode;
|
||
onNodeDoubleClick?.(graphNode);
|
||
};
|
||
|
||
// Path highlight on hover
|
||
const handleNodeMouseEnter = (_event: MouseEvent, node: Node) => {
|
||
setHoveredNodeId(node.id);
|
||
};
|
||
|
||
const handleNodeMouseLeave = () => {
|
||
setHoveredNodeId(null);
|
||
};
|
||
|
||
return (
|
||
<div ref={containerRef} className="h-full w-full">
|
||
<ReactFlow
|
||
nodes={enrichedNodes}
|
||
edges={enrichedEdges}
|
||
nodeTypes={NODE_TYPES}
|
||
onNodeClick={handleNodeClick}
|
||
onNodeDoubleClick={handleNodeDoubleClick}
|
||
onNodeMouseEnter={handleNodeMouseEnter}
|
||
onNodeMouseLeave={handleNodeMouseLeave}
|
||
fitView
|
||
fitViewOptions={{ ...AUTO_FIT_OPTIONS, includeHiddenNodes: true }}
|
||
// 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 }}
|
||
>
|
||
<Background />
|
||
<MiniMap
|
||
pannable
|
||
zoomable
|
||
ariaLabel="Graph minimap"
|
||
bgColor={minimapColors.bg}
|
||
maskColor={minimapColors.mask}
|
||
maskStrokeColor={minimapColors.maskStroke}
|
||
maskStrokeWidth={MINIMAP_VIEWPORT_STROKE_WIDTH}
|
||
nodeColor={(node) => {
|
||
const graphNode = (node.data as { graphNode?: GraphNode })
|
||
.graphNode;
|
||
if (!graphNode) return MINIMAP_COLORS.light.maskStroke;
|
||
return getNodeColor(graphNode.labels, graphNode.properties);
|
||
}}
|
||
nodeStrokeColor={(node) => {
|
||
const graphNode = (node.data as { graphNode?: GraphNode })
|
||
.graphNode;
|
||
if (!graphNode) return "transparent";
|
||
return getNodeBorderColor(graphNode.labels, graphNode.properties);
|
||
}}
|
||
/>
|
||
</ReactFlow>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Outer component: renders ReactFlowProvider ---
|
||
|
||
export const AttackPathGraph = ({
|
||
data,
|
||
selectedNodeId,
|
||
isFilteredView,
|
||
initialNodeId,
|
||
expandedResources,
|
||
expandedClasses,
|
||
outcome,
|
||
view,
|
||
onNodeClick,
|
||
onNodeDoubleClick,
|
||
onInitialFilter,
|
||
ref,
|
||
className,
|
||
}: AttackPathGraphProps) => {
|
||
return (
|
||
<div
|
||
role="img"
|
||
aria-label="Attack path graph"
|
||
className={cn(
|
||
"dark:bg-bg-neutral-secondary bg-bg-neutral-secondary h-full w-full rounded-lg",
|
||
className,
|
||
)}
|
||
>
|
||
<style>{GRAPH_STYLES}</style>
|
||
<GraphDefs />
|
||
<ReactFlowProvider>
|
||
<GraphCanvas
|
||
ref={ref}
|
||
data={data}
|
||
selectedNodeId={selectedNodeId}
|
||
isFilteredView={isFilteredView}
|
||
initialNodeId={initialNodeId}
|
||
expandedResources={expandedResources}
|
||
expandedClasses={expandedClasses}
|
||
outcome={outcome}
|
||
view={view}
|
||
onNodeClick={onNodeClick}
|
||
onNodeDoubleClick={onNodeDoubleClick}
|
||
onInitialFilter={onInitialFilter}
|
||
/>
|
||
</ReactFlowProvider>
|
||
</div>
|
||
);
|
||
};
|