Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
744df8d
refactor(ui): normalize graph edge types and remove dead code
Apr 15, 2026
fb8e8a0
fix(ui): add key to Suspense in navbar to prevent stale fallback
Apr 15, 2026
cd5fdc9
refactor(ui): move layout derivation into GraphCanvas inner component
Apr 15, 2026
58c8efa
refactor(ui): replace D3 graph rendering with React Flow
Apr 15, 2026
0ac545b
refactor(ui): apply code review fixes to React Flow graph
Apr 16, 2026
b8aca18
perf(ui): fix O(n*e) lookup in attack path graph
Apr 16, 2026
bc13241
feat(ui): add graph interactions and filtered view
Apr 16, 2026
9444aeb
perf(ui): fix O(n*e) lookup in computeFilteredSubgraph
Apr 16, 2026
d01b532
fix(ui): wire View Finding handler into NodeDetailPanel after rebase
Apr 17, 2026
1031673
Merge remote-tracking branch 'origin/PROWLER-1273/react-flow-migratio…
Apr 17, 2026
d4dac34
fix(ui): add key to Suspense in navbar to prevent stale fallback
Apr 15, 2026
0f4a112
refactor(ui): move layout derivation into GraphCanvas inner component
Apr 15, 2026
1610b69
refactor(ui): replace D3 graph rendering with React Flow
Apr 15, 2026
9fe4e30
refactor(ui): apply code review fixes to React Flow graph
Apr 16, 2026
a41ec0d
perf(ui): fix O(n*e) lookup in attack path graph
Apr 16, 2026
ce9ce84
refactor(ui): extract shared graph node primitives
Apr 29, 2026
962337d
test(ui): cover layoutWithDagre and graph Export disabled state
Apr 29, 2026
8c60510
docs(ui): drop non-standard Tests section from CHANGELOG
Apr 29, 2026
b3e08a6
chore(ui): exclude .expect/ from eslint scanning
Apr 30, 2026
f878f12
fix(ui): reset graph interactions and wire fullscreen finding view
Apr 30, 2026
8391db5
Merge branch 'PROWLER-1374/replace-d3-with-react-flow' into PROWLER-1…
Apr 30, 2026
5d393b2
chore(ui): remove .expect tooling artifact and eslint exclusion
Apr 30, 2026
fe26eda
Merge branch 'PROWLER-1273/react-flow-migration' into PROWLER-1375/gr…
May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,21 @@ import {
ReactFlowProvider,
useReactFlow,
} from "@xyflow/react";
import { type MouseEvent, type Ref, useImperativeHandle, useRef } from "react";
import {
type MouseEvent,
type Ref,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";

import { cn } from "@/lib/utils";
import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";

import { getPathEdges, GRAPH_EDGE_HIGHLIGHT_COLOR } from "../../_lib";
import { computeFilteredSubgraph } from "../../_lib/graph-utils";
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
import { FindingNode } from "./nodes/finding-node";
import { InternetNode } from "./nodes/internet-node";
Expand All @@ -32,7 +42,10 @@ export interface GraphHandle {
interface AttackPathGraphProps {
data: AttackPathGraphData;
selectedNodeId?: string | null;
isFilteredView?: boolean;
initialNodeId?: string;
onNodeClick?: (node: GraphNode) => void;
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
ref?: Ref<GraphHandle>;
className?: string;
}
Expand All @@ -45,7 +58,7 @@ const NODE_TYPES = {
resource: ResourceNode,
} as const;

// --- CSS for animated dashed edges and selected node pulse ---
// --- CSS for animated dashed edges, selected node pulse, and edge highlight ---

const GRAPH_STYLES = `
@keyframes dash {
Expand All @@ -62,8 +75,18 @@ const GRAPH_STYLES = `
.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 = "#f97316";

// --- SVG filter defs (shared by all node components) ---

const GraphDefs = () => (
Expand All @@ -83,7 +106,7 @@ const GraphDefs = () => (
dx="0"
dy="0"
stdDeviation="4"
floodColor="#ef4444"
floodColor={GRAPH_FINDING_GLOW_COLOR}
floodOpacity="0.6"
/>
</filter>
Expand All @@ -93,7 +116,7 @@ const GraphDefs = () => (
dx="0"
dy="0"
stdDeviation="6"
floodColor="#f97316"
floodColor={GRAPH_SELECTED_GLOW_COLOR}
floodOpacity="0.8"
/>
</filter>
Expand All @@ -103,48 +126,153 @@ const GraphDefs = () => (

// --- Inner component: calls useReactFlow(), owns layout derivation ---

interface GraphCanvasProps {
data: AttackPathGraphData;
selectedNodeId?: string | null;
onNodeClick?: (node: GraphNode) => void;
ref?: Ref<GraphHandle>;
}
type GraphCanvasProps = Omit<AttackPathGraphProps, "className">;

const GraphCanvas = ({
data,
selectedNodeId,
isFilteredView,
initialNodeId,
onNodeClick,
onInitialFilter,
ref,
}: GraphCanvasProps) => {
const { zoomIn, zoomOut, fitView, getZoom } = useReactFlow();
const containerRef = useRef<HTMLDivElement>(null);
const hasInitialized = useRef(false);

// Tier 1 state: which resource nodes have their findings expanded
const [expandedResources, setExpandedResources] = useState<Set<string>>(
new Set(),
);
// Path highlight state
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);

// Reset interaction state whenever the underlying graph data changes
// (e.g. scan switch or new query execution) to avoid leaking stale
// expansion / highlight state into the next graph.
useEffect(() => {
setExpandedResources(new Set());
setHoveredNodeId(null);
}, [data]);

const nodes = data.nodes ?? [];
const edges = data.edges ?? [];
// --- 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

const nodes = effectiveData.nodes ?? [];
const edges = effectiveData.edges ?? [];

// Derive RF nodes and edges from data (pure computation in render body β€” D4)
const { rfNodes, rfEdges } = layoutWithDagre(nodes, edges);

// Pre-compute which resources have findings connected (O(n+e))
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
const findingNodeIds = new Set<string>();
const resourceToFindings = new Map<string, Set<string>>();
const findingToResources = new Map<string, Set<string>>();

nodes.forEach((n) => {
if (isFindingNode(n.labels)) findingNodeIds.add(n.id);
});

const resourcesWithFindings = new Set<string>();
edges.forEach((edge) => {
if (isFindingNode(nodeLabelMap.get(edge.source) ?? []))
const sourceIsFinding = findingNodeIds.has(edge.source);
const targetIsFinding = findingNodeIds.has(edge.target);

if (sourceIsFinding) {
resourcesWithFindings.add(edge.target);
if (isFindingNode(nodeLabelMap.get(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);
}
});

// Enrich nodes with selection and hasFindings state
// Tier 1: compute which finding nodes are hidden (not expanded)
const hiddenFindingIds = new Set<string>();
if (!isFilteredView) {
findingNodeIds.forEach((findingId) => {
// A finding is visible only if at least one of its connected resources is expanded
const connectedResources = findingToResources.get(findingId);
if (!connectedResources) {
hiddenFindingIds.add(findingId);
return;
}
const anyExpanded = Array.from(connectedResources).some((resId) =>
expandedResources.has(resId),
);
if (!anyExpanded) {
hiddenFindingIds.add(findingId);
}
});
}

// Path highlight: compute highlighted edge IDs
const highlightedEdgeIds = hoveredNodeId
? getPathEdges(
hoveredNodeId,
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),
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 isHighlighted = highlightedEdgeIds.has(edge.id);

return {
...edge,
hidden: sourceHidden || targetHidden,
className: cn(edge.className, isHighlighted && "highlighted"),
};
});

useImperativeHandle(ref, () => ({
zoomIn: () => zoomIn({ duration: 300 }),
zoomOut: () => zoomOut({ duration: 300 }),
Expand All @@ -155,16 +283,44 @@ 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)) {
setExpandedResources((prev) => {
const next = new Set(prev);
if (next.has(node.id)) {
next.delete(node.id);
} else {
next.add(node.id);
}
return next;
});
}
}

// Always fire parent callback (handles selection + Tier 2 filtered view)
onNodeClick?.(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={rfEdges}
edges={enrichedEdges}
nodeTypes={NODE_TYPES}
onNodeClick={handleNodeClick}
onNodeMouseEnter={handleNodeMouseEnter}
onNodeMouseLeave={handleNodeMouseLeave}
fitView
fitViewOptions={{ padding: 0.2 }}
zoomOnScroll={false}
Expand All @@ -186,7 +342,10 @@ const GraphCanvas = ({
export const AttackPathGraph = ({
data,
selectedNodeId,
isFilteredView,
initialNodeId,
onNodeClick,
onInitialFilter,
ref,
className,
}: AttackPathGraphProps) => {
Expand All @@ -206,7 +365,10 @@ export const AttackPathGraph = ({
ref={ref}
data={data}
selectedNodeId={selectedNodeId}
isFilteredView={isFilteredView}
initialNodeId={initialNodeId}
onNodeClick={onNodeClick}
onInitialFilter={onInitialFilter}
/>
</ReactFlowProvider>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,12 @@ export const computeFilteredSubgraph = (
traverseDownstream(targetNodeId);

// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
const sourceNode = nodes.find((n) => n.id === edge.source);
const targetNode = nodes.find((n) => n.id === edge.target);

const sourceIsFinding = sourceNode?.labels.some((l) =>
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);
const targetIsFinding = targetNode?.labels.some((l) =>
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);

Expand Down
Loading