@@ -9,11 +9,21 @@ import {
99 ReactFlowProvider ,
1010 useReactFlow ,
1111} from "@xyflow/react" ;
12- import { type MouseEvent , type Ref , useImperativeHandle , useRef } from "react" ;
12+ import {
13+ type MouseEvent ,
14+ type Ref ,
15+ useEffect ,
16+ useImperativeHandle ,
17+ useLayoutEffect ,
18+ useRef ,
19+ useState ,
20+ } from "react" ;
1321
1422import { cn } from "@/lib/utils" ;
1523import type { AttackPathGraphData , GraphNode } from "@/types/attack-paths" ;
1624
25+ import { getPathEdges , GRAPH_EDGE_HIGHLIGHT_COLOR } from "../../_lib" ;
26+ import { computeFilteredSubgraph } from "../../_lib/graph-utils" ;
1727import { isFindingNode , layoutWithDagre } from "../../_lib/layout" ;
1828import { FindingNode } from "./nodes/finding-node" ;
1929import { InternetNode } from "./nodes/internet-node" ;
@@ -32,7 +42,10 @@ export interface GraphHandle {
3242interface AttackPathGraphProps {
3343 data : AttackPathGraphData ;
3444 selectedNodeId ?: string | null ;
45+ isFilteredView ?: boolean ;
46+ initialNodeId ?: string ;
3547 onNodeClick ?: ( node : GraphNode ) => void ;
48+ onInitialFilter ?: ( filteredData : AttackPathGraphData ) => void ;
3649 ref ?: Ref < GraphHandle > ;
3750 className ?: string ;
3851}
@@ -45,7 +58,7 @@ const NODE_TYPES = {
4558 resource : ResourceNode ,
4659} as const ;
4760
48- // --- CSS for animated dashed edges and selected node pulse ---
61+ // --- CSS for animated dashed edges, selected node pulse, and edge highlight ---
4962
5063const GRAPH_STYLES = `
5164 @keyframes dash {
@@ -62,8 +75,18 @@ const GRAPH_STYLES = `
6275 .selected-node {
6376 animation: selectedPulse 1.2s ease-in-out infinite;
6477 }
78+ .react-flow .highlighted .react-flow__edge-path {
79+ stroke: ${ GRAPH_EDGE_HIGHLIGHT_COLOR } ;
80+ stroke-width: 3;
81+ filter: drop-shadow(0 0 4px ${ GRAPH_EDGE_HIGHLIGHT_COLOR } );
82+ }
6583` ;
6684
85+ // --- SVG filter color constants ---
86+
87+ const GRAPH_FINDING_GLOW_COLOR = "#ef4444" ;
88+ const GRAPH_SELECTED_GLOW_COLOR = "#f97316" ;
89+
6790// --- SVG filter defs (shared by all node components) ---
6891
6992const GraphDefs = ( ) => (
@@ -83,7 +106,7 @@ const GraphDefs = () => (
83106 dx = "0"
84107 dy = "0"
85108 stdDeviation = "4"
86- floodColor = "#ef4444"
109+ floodColor = { GRAPH_FINDING_GLOW_COLOR }
87110 floodOpacity = "0.6"
88111 />
89112 </ filter >
@@ -93,7 +116,7 @@ const GraphDefs = () => (
93116 dx = "0"
94117 dy = "0"
95118 stdDeviation = "6"
96- floodColor = "#f97316"
119+ floodColor = { GRAPH_SELECTED_GLOW_COLOR }
97120 floodOpacity = "0.8"
98121 />
99122 </ filter >
@@ -103,48 +126,153 @@ const GraphDefs = () => (
103126
104127// --- Inner component: calls useReactFlow(), owns layout derivation ---
105128
106- interface GraphCanvasProps {
107- data : AttackPathGraphData ;
108- selectedNodeId ?: string | null ;
109- onNodeClick ?: ( node : GraphNode ) => void ;
110- ref ?: Ref < GraphHandle > ;
111- }
129+ type GraphCanvasProps = Omit < AttackPathGraphProps , "className" > ;
112130
113131const GraphCanvas = ( {
114132 data,
115133 selectedNodeId,
134+ isFilteredView,
135+ initialNodeId,
116136 onNodeClick,
137+ onInitialFilter,
117138 ref,
118139} : GraphCanvasProps ) => {
119140 const { zoomIn, zoomOut, fitView, getZoom } = useReactFlow ( ) ;
120141 const containerRef = useRef < HTMLDivElement > ( null ) ;
142+ const hasInitialized = useRef ( false ) ;
143+
144+ // Tier 1 state: which resource nodes have their findings expanded
145+ const [ expandedResources , setExpandedResources ] = useState < Set < string > > (
146+ new Set ( ) ,
147+ ) ;
148+ // Path highlight state
149+ const [ hoveredNodeId , setHoveredNodeId ] = useState < string | null > ( null ) ;
150+
151+ // Reset interaction state whenever the underlying graph data changes
152+ // (e.g. scan switch or new query execution) to avoid leaking stale
153+ // expansion / highlight state into the next graph.
154+ useEffect ( ( ) => {
155+ setExpandedResources ( new Set ( ) ) ;
156+ setHoveredNodeId ( null ) ;
157+ } , [ data ] ) ;
121158
122- const nodes = data . nodes ?? [ ] ;
123- const edges = data . edges ?? [ ] ;
159+ // --- initialNodeId: synchronous filtered-view derivation on first render ---
160+ // Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
161+ let effectiveData = data ;
162+ if (
163+ initialNodeId &&
164+ ! hasInitialized . current &&
165+ data . nodes . some ( ( n ) => n . id === initialNodeId )
166+ ) {
167+ effectiveData = computeFilteredSubgraph ( data , initialNodeId ) ;
168+ }
169+
170+ // Sync store flags via useLayoutEffect (runs before paint)
171+ useLayoutEffect ( ( ) => {
172+ if ( hasInitialized . current ) return ;
173+ hasInitialized . current = true ;
174+ if (
175+ initialNodeId &&
176+ data . nodes . some ( ( n ) => n . id === initialNodeId ) &&
177+ onInitialFilter
178+ ) {
179+ onInitialFilter ( effectiveData ) ;
180+ }
181+ } , [ ] ) ; // eslint-disable-line react-hooks/exhaustive-deps -- one-time init
182+
183+ const nodes = effectiveData . nodes ?? [ ] ;
184+ const edges = effectiveData . edges ?? [ ] ;
124185
125186 // Derive RF nodes and edges from data (pure computation in render body — D4)
126187 const { rfNodes, rfEdges } = layoutWithDagre ( nodes , edges ) ;
127188
128189 // Pre-compute which resources have findings connected (O(n+e))
129- const nodeLabelMap = new Map ( nodes . map ( ( n ) => [ n . id , n . labels ] ) ) ;
190+ const findingNodeIds = new Set < string > ( ) ;
191+ const resourceToFindings = new Map < string , Set < string > > ( ) ;
192+ const findingToResources = new Map < string , Set < string > > ( ) ;
193+
194+ nodes . forEach ( ( n ) => {
195+ if ( isFindingNode ( n . labels ) ) findingNodeIds . add ( n . id ) ;
196+ } ) ;
197+
130198 const resourcesWithFindings = new Set < string > ( ) ;
131199 edges . forEach ( ( edge ) => {
132- if ( isFindingNode ( nodeLabelMap . get ( edge . source ) ?? [ ] ) )
200+ const sourceIsFinding = findingNodeIds . has ( edge . source ) ;
201+ const targetIsFinding = findingNodeIds . has ( edge . target ) ;
202+
203+ if ( sourceIsFinding ) {
133204 resourcesWithFindings . add ( edge . target ) ;
134- if ( isFindingNode ( nodeLabelMap . get ( edge . target ) ?? [ ] ) )
205+ // Map resource → its findings
206+ const findings = resourceToFindings . get ( edge . target ) ?? new Set ( ) ;
207+ findings . add ( edge . source ) ;
208+ resourceToFindings . set ( edge . target , findings ) ;
209+ // Map finding → its resources
210+ const resources = findingToResources . get ( edge . source ) ?? new Set ( ) ;
211+ resources . add ( edge . target ) ;
212+ findingToResources . set ( edge . source , resources ) ;
213+ }
214+ if ( targetIsFinding ) {
135215 resourcesWithFindings . add ( edge . source ) ;
216+ const findings = resourceToFindings . get ( edge . source ) ?? new Set ( ) ;
217+ findings . add ( edge . target ) ;
218+ resourceToFindings . set ( edge . source , findings ) ;
219+ const resources = findingToResources . get ( edge . target ) ?? new Set ( ) ;
220+ resources . add ( edge . source ) ;
221+ findingToResources . set ( edge . target , resources ) ;
222+ }
136223 } ) ;
137224
138- // Enrich nodes with selection and hasFindings state
225+ // Tier 1: compute which finding nodes are hidden (not expanded)
226+ const hiddenFindingIds = new Set < string > ( ) ;
227+ if ( ! isFilteredView ) {
228+ findingNodeIds . forEach ( ( findingId ) => {
229+ // A finding is visible only if at least one of its connected resources is expanded
230+ const connectedResources = findingToResources . get ( findingId ) ;
231+ if ( ! connectedResources ) {
232+ hiddenFindingIds . add ( findingId ) ;
233+ return ;
234+ }
235+ const anyExpanded = Array . from ( connectedResources ) . some ( ( resId ) =>
236+ expandedResources . has ( resId ) ,
237+ ) ;
238+ if ( ! anyExpanded ) {
239+ hiddenFindingIds . add ( findingId ) ;
240+ }
241+ } ) ;
242+ }
243+
244+ // Path highlight: compute highlighted edge IDs
245+ const highlightedEdgeIds = hoveredNodeId
246+ ? getPathEdges (
247+ hoveredNodeId ,
248+ rfEdges . map ( ( e ) => ( { sourceId : e . source , targetId : e . target } ) ) ,
249+ )
250+ : new Set < string > ( ) ;
251+
252+ // Enrich nodes with selection, hasFindings, and hidden state
139253 const enrichedNodes = rfNodes . map ( ( node ) => ( {
140254 ...node ,
141255 selected : node . id === selectedNodeId ,
256+ hidden : hiddenFindingIds . has ( node . id ) ,
142257 data : {
143258 ...node . data ,
144259 hasFindings : resourcesWithFindings . has ( node . id ) ,
145260 } ,
146261 } ) ) ;
147262
263+ // Enrich edges with hidden state (hide edges to hidden findings) and highlight
264+ const enrichedEdges = rfEdges . map ( ( edge ) => {
265+ const sourceHidden = hiddenFindingIds . has ( edge . source ) ;
266+ const targetHidden = hiddenFindingIds . has ( edge . target ) ;
267+ const isHighlighted = highlightedEdgeIds . has ( edge . id ) ;
268+
269+ return {
270+ ...edge ,
271+ hidden : sourceHidden || targetHidden ,
272+ className : cn ( edge . className , isHighlighted && "highlighted" ) ,
273+ } ;
274+ } ) ;
275+
148276 useImperativeHandle ( ref , ( ) => ( {
149277 zoomIn : ( ) => zoomIn ( { duration : 300 } ) ,
150278 zoomOut : ( ) => zoomOut ( { duration : 300 } ) ,
@@ -155,16 +283,44 @@ const GraphCanvas = ({
155283
156284 const handleNodeClick = ( _event : MouseEvent , node : Node ) => {
157285 const graphNode = ( node . data as { graphNode : GraphNode } ) . graphNode ;
286+
287+ // Tier 1: clicking resource in full view toggles connected findings
288+ if ( ! isFilteredView && ! isFindingNode ( graphNode . labels ) ) {
289+ if ( resourcesWithFindings . has ( node . id ) ) {
290+ setExpandedResources ( ( prev ) => {
291+ const next = new Set ( prev ) ;
292+ if ( next . has ( node . id ) ) {
293+ next . delete ( node . id ) ;
294+ } else {
295+ next . add ( node . id ) ;
296+ }
297+ return next ;
298+ } ) ;
299+ }
300+ }
301+
302+ // Always fire parent callback (handles selection + Tier 2 filtered view)
158303 onNodeClick ?.( graphNode ) ;
159304 } ;
160305
306+ // Path highlight on hover
307+ const handleNodeMouseEnter = ( _event : MouseEvent , node : Node ) => {
308+ setHoveredNodeId ( node . id ) ;
309+ } ;
310+
311+ const handleNodeMouseLeave = ( ) => {
312+ setHoveredNodeId ( null ) ;
313+ } ;
314+
161315 return (
162316 < div ref = { containerRef } className = "h-full w-full" >
163317 < ReactFlow
164318 nodes = { enrichedNodes }
165- edges = { rfEdges }
319+ edges = { enrichedEdges }
166320 nodeTypes = { NODE_TYPES }
167321 onNodeClick = { handleNodeClick }
322+ onNodeMouseEnter = { handleNodeMouseEnter }
323+ onNodeMouseLeave = { handleNodeMouseLeave }
168324 fitView
169325 fitViewOptions = { { padding : 0.2 } }
170326 zoomOnScroll = { false }
@@ -186,7 +342,10 @@ const GraphCanvas = ({
186342export const AttackPathGraph = ( {
187343 data,
188344 selectedNodeId,
345+ isFilteredView,
346+ initialNodeId,
189347 onNodeClick,
348+ onInitialFilter,
190349 ref,
191350 className,
192351} : AttackPathGraphProps ) => {
@@ -206,7 +365,10 @@ export const AttackPathGraph = ({
206365 ref = { ref }
207366 data = { data }
208367 selectedNodeId = { selectedNodeId }
368+ isFilteredView = { isFilteredView }
369+ initialNodeId = { initialNodeId }
209370 onNodeClick = { onNodeClick }
371+ onInitialFilter = { onInitialFilter }
210372 />
211373 </ ReactFlowProvider >
212374 </ div >
0 commit comments