Skip to content

Commit 1d54244

Browse files
pfe-nazariesPablo F.Gclaude
authored
[CHAIN] feat(ui): add graph interactions and filtered view (#10756)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ff2bf5b commit 1d54244

4 files changed

Lines changed: 317 additions & 111 deletions

File tree

ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx

Lines changed: 179 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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

25+
import { getPathEdges, GRAPH_EDGE_HIGHLIGHT_COLOR } from "../../_lib";
26+
import { computeFilteredSubgraph } from "../../_lib/graph-utils";
1727
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
1828
import { FindingNode } from "./nodes/finding-node";
1929
import { InternetNode } from "./nodes/internet-node";
@@ -32,7 +42,10 @@ export interface GraphHandle {
3242
interface 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

5063
const 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

6992
const 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

113131
const 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 = ({
186342
export 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>

ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,12 @@ export const computeFilteredSubgraph = (
6565
traverseDownstream(targetNodeId);
6666

6767
// Also include findings directly connected to the selected node
68+
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
6869
edges.forEach((edge) => {
69-
const sourceNode = nodes.find((n) => n.id === edge.source);
70-
const targetNode = nodes.find((n) => n.id === edge.target);
71-
72-
const sourceIsFinding = sourceNode?.labels.some((l) =>
70+
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
7371
l.toLowerCase().includes("finding"),
7472
);
75-
const targetIsFinding = targetNode?.labels.some((l) =>
73+
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
7674
l.toLowerCase().includes("finding"),
7775
);
7876

0 commit comments

Comments
 (0)