Skip to content

Commit 5ed1634

Browse files
pfe-nazariesPablo F.GclaudeAlan-TheGentleman
authored
[CHAIN] fix(ui): re-fit attack-path viewport on filter and expand, harden minimap (#11010)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
1 parent 4d5a77a commit 5ed1634

4 files changed

Lines changed: 591 additions & 170 deletions

File tree

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

Lines changed: 172 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,7 @@ interface AttackPathGraphProps {
5353
selectedNodeId?: string | null;
5454
isFilteredView?: boolean;
5555
initialNodeId?: string;
56-
// Tier 1 expansion state — controlled by the parent (lives in the graph
57-
// store) so it survives filtered-view enter/exit. If omitted, no resource
58-
// expansion is tracked and findings stay hidden in the full view.
5956
expandedResources?: Set<string>;
60-
onResourceToggle?: (resourceId: string) => void;
6157
onNodeClick?: (node: GraphNode) => void;
6258
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
6359
ref?: Ref<GraphHandle>;
@@ -144,32 +140,81 @@ const GraphDefs = () => (
144140

145141
type GraphCanvasProps = Omit<AttackPathGraphProps, "className">;
146142

143+
// Mask covers the area NOT currently in view; the cut-out rectangle is the
144+
// viewport. To make the viewport rectangle stand out we darken the mask and
145+
// give its border high contrast against the minimap background.
147146
const MINIMAP_COLORS = {
148147
light: {
149148
bg: "#f8fafc",
150-
mask: "rgba(241, 245, 249, 0.6)",
151-
maskStroke: "#cbd5e1",
149+
mask: "rgba(15, 23, 42, 0.45)",
150+
maskStroke: "#0f172a",
152151
},
153152
dark: {
154153
bg: "#0f172a",
155-
mask: "rgba(15, 23, 42, 0.6)",
156-
maskStroke: "#475569",
154+
mask: "rgba(0, 0, 0, 0.7)",
155+
maskStroke: "#cbd5e1",
157156
},
158157
} as const;
159158

159+
const MINIMAP_VIEWPORT_STROKE_WIDTH = 3;
160+
161+
// Animated re-fit shared by every auto-fit trigger. We deliberately do not
162+
// cap maxZoom — for small subgraphs (e.g. a finding's filtered view with 3
163+
// nodes) the user expects the result to fill the canvas; an artificial cap
164+
// looks like a layout error.
165+
//
166+
// Padding is asymmetric: the minimap sits in the bottom-right corner of the
167+
// canvas (default 200×150 panel + offset), so a fit with uniform padding
168+
// can drop nodes underneath it. Generous bottom/right padding keeps the
169+
// fitted graph clear of the minimap.
170+
const AUTO_FIT_OPTIONS = {
171+
padding: { top: "32px", left: "32px", right: "240px", bottom: "180px" },
172+
duration: 300,
173+
} as const;
174+
175+
const MEASURED_FIT_MAX_ATTEMPTS = 30;
176+
177+
const scheduleMeasuredFit = (
178+
isMeasured: () => boolean,
179+
onMeasured: () => void,
180+
) => {
181+
let frame = 0;
182+
let attempts = 0;
183+
184+
const tryFit = () => {
185+
if (isMeasured() || attempts >= MEASURED_FIT_MAX_ATTEMPTS) {
186+
onMeasured();
187+
return;
188+
}
189+
190+
attempts += 1;
191+
frame = requestAnimationFrame(tryFit);
192+
};
193+
194+
frame = requestAnimationFrame(tryFit);
195+
196+
return () => cancelAnimationFrame(frame);
197+
};
198+
160199
const GraphCanvas = ({
161200
data,
162201
selectedNodeId,
163202
isFilteredView,
164203
initialNodeId,
165204
expandedResources,
166-
onResourceToggle,
167205
onNodeClick,
168206
onInitialFilter,
169207
ref,
170208
}: GraphCanvasProps) => {
171-
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
172-
useReactFlow();
209+
const {
210+
zoomIn,
211+
zoomOut,
212+
fitView,
213+
getZoom,
214+
getNodes,
215+
getNodesBounds,
216+
getViewport,
217+
} = useReactFlow();
173218
const { resolvedTheme } = useTheme();
174219
const containerRef = useRef<HTMLDivElement>(null);
175220
const hasInitialized = useRef(false);
@@ -212,6 +257,112 @@ const GraphCanvas = ({
212257
}
213258
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- one-time init
214259

260+
// --- Auto-fit triggers ---
261+
//
262+
// Filter toggles (finding click → filtered view, "Back to Full View" →
263+
// full graph) swap the data prop, which leaves the previous viewport
264+
// pointing at coordinates that no longer contain the new layout. Re-fit
265+
// once React Flow has applied the new layout (next animation frame).
266+
//
267+
// Resource expansion fits ONLY when a newly revealed finding sits
268+
// entirely outside the current viewport (i.e. its full bounding box
269+
// is past one of the viewport edges). This intentionally lets
270+
// partially-clipped edge nodes through without re-fitting — hidden
271+
// findings contribute zero size to the initial bbox, so a finding's
272+
// far edge often peeks past the padded viewport on the first reveal,
273+
// and a "partially outside" check would re-fit on every expand. The
274+
// user's "no cabe visualmente" complaint is about findings that
275+
// appear completely off-screen after the user has panned away, which
276+
// the strict check captures.
277+
const filteredFitInitRef = useRef(false);
278+
const previousFilteredRef = useRef(isFilteredView);
279+
const previousExpandedRef = useRef<ReadonlySet<string>>(expanded);
280+
// Captured every render so the expand-fit effect can resolve a resource
281+
// ID to its connected finding IDs without recomputing the lookup.
282+
// The map itself is built later in this render — see assignment below
283+
// the layout-derivation block.
284+
const resourceToFindingsRef = useRef<Map<string, Set<string>>>(new Map());
285+
286+
useEffect(() => {
287+
if (!filteredFitInitRef.current) {
288+
filteredFitInitRef.current = true;
289+
previousFilteredRef.current = isFilteredView;
290+
return;
291+
}
292+
if (previousFilteredRef.current === isFilteredView) return;
293+
previousFilteredRef.current = isFilteredView;
294+
// React Flow measures node sizes asynchronously via ResizeObserver after
295+
// the data swap. A single rAF runs while measured.width is still 0, so
296+
// fitView computes a degenerate bbox and the viewport keeps the user's
297+
// previous zoom — most visibly when leaving a filtered view in which the
298+
// user had zoomed in. Poll until visible nodes are measured (or give up
299+
// after ~500ms so we never block on a stuck observer).
300+
return scheduleMeasuredFit(
301+
() => {
302+
const visibleNodes = getNodes().filter((n) => !n.hidden);
303+
return (
304+
visibleNodes.length > 0 &&
305+
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
306+
);
307+
},
308+
() => fitView(AUTO_FIT_OPTIONS),
309+
);
310+
}, [isFilteredView, fitView, getNodes]);
311+
312+
useEffect(() => {
313+
const previous = previousExpandedRef.current;
314+
previousExpandedRef.current = expanded;
315+
if (previous === expanded) return;
316+
const newResourceIds = Array.from(expanded).filter(
317+
(id) => !previous.has(id),
318+
);
319+
// Only fit on growth — collapsing intentionally leaves the user's
320+
// current framing alone.
321+
if (newResourceIds.length === 0) return;
322+
323+
const newFindingIds = new Set<string>();
324+
for (const resourceId of newResourceIds) {
325+
const findings = resourceToFindingsRef.current.get(resourceId);
326+
if (!findings) continue;
327+
findings.forEach((id) => newFindingIds.add(id));
328+
}
329+
if (newFindingIds.size === 0) return;
330+
331+
// Findings transition from hidden to visible on expand, and React Flow
332+
// measures them asynchronously. Poll before checking whether their full
333+
// bounding boxes sit entirely past a viewport edge; collapsing and
334+
// partially clipped findings preserve the user's current frame.
335+
return scheduleMeasuredFit(
336+
() => {
337+
const targets = getNodes().filter((n) => newFindingIds.has(n.id));
338+
return (
339+
targets.length === newFindingIds.size &&
340+
targets.every((n) => (n.measured?.width ?? 0) > 0)
341+
);
342+
},
343+
() => {
344+
const targets = getNodes().filter((n) => newFindingIds.has(n.id));
345+
const containerEl = containerRef.current;
346+
if (!containerEl) return;
347+
const { width, height } = containerEl.getBoundingClientRect();
348+
if (width === 0 || height === 0) return;
349+
const { x, y, zoom } = getViewport();
350+
const minX = -x / zoom;
351+
const minY = -y / zoom;
352+
const maxX = minX + width / zoom;
353+
const maxY = minY + height / zoom;
354+
const anyOutside = targets.some((node) => {
355+
const nx = node.position.x;
356+
const ny = node.position.y;
357+
const nw = node.measured?.width ?? 0;
358+
const nh = node.measured?.height ?? 0;
359+
return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY;
360+
});
361+
if (anyOutside) fitView(AUTO_FIT_OPTIONS);
362+
},
363+
);
364+
}, [expanded, fitView, getNodes, getViewport]);
365+
215366
const nodes = effectiveData.nodes ?? [];
216367
const edges = effectiveData.edges ?? [];
217368

@@ -254,6 +405,9 @@ const GraphCanvas = ({
254405
}
255406
});
256407

408+
// Refresh the expand-fit effect's lookup with the latest mapping.
409+
resourceToFindingsRef.current = resourceToFindings;
410+
257411
// Tier 1: compute which finding nodes are hidden (not expanded)
258412
const hiddenFindingIds = new Set<string>();
259413
if (!isFilteredView) {
@@ -321,13 +475,6 @@ const GraphCanvas = ({
321475
const handleNodeClick = (_event: MouseEvent, node: Node) => {
322476
const graphNode = (node.data as { graphNode: GraphNode }).graphNode;
323477

324-
// Tier 1: clicking resource in full view toggles connected findings
325-
if (!isFilteredView && !isFindingNode(graphNode.labels)) {
326-
if (resourcesWithFindings.has(node.id)) {
327-
onResourceToggle?.(node.id);
328-
}
329-
}
330-
331478
// Always fire parent callback (handles selection + Tier 2 filtered view)
332479
onNodeClick?.(graphNode);
333480
};
@@ -351,11 +498,15 @@ const GraphCanvas = ({
351498
onNodeMouseEnter={handleNodeMouseEnter}
352499
onNodeMouseLeave={handleNodeMouseLeave}
353500
fitView
354-
fitViewOptions={{ padding: 0.2 }}
355-
zoomOnScroll={false}
501+
fitViewOptions={{ padding: 0.2, includeHiddenNodes: true }}
502+
// Supported React Flow behavior: wheel over the graph zooms the
503+
// viewport. The surrounding UX avoids using node details as inline
504+
// content, so this no longer fights a below-graph details section.
505+
zoomOnScroll={true}
356506
zoomOnPinch={true}
357507
zoomOnDoubleClick={false}
358508
panOnScroll={false}
509+
preventScrolling={true}
359510
minZoom={0.1}
360511
maxZoom={10}
361512
proOptions={{ hideAttribution: true }}
@@ -368,6 +519,7 @@ const GraphCanvas = ({
368519
bgColor={minimapColors.bg}
369520
maskColor={minimapColors.mask}
370521
maskStrokeColor={minimapColors.maskStroke}
522+
maskStrokeWidth={MINIMAP_VIEWPORT_STROKE_WIDTH}
371523
nodeColor={(node) => {
372524
const graphNode = (node.data as { graphNode?: GraphNode })
373525
.graphNode;
@@ -394,7 +546,6 @@ export const AttackPathGraph = ({
394546
isFilteredView,
395547
initialNodeId,
396548
expandedResources,
397-
onResourceToggle,
398549
onNodeClick,
399550
onInitialFilter,
400551
ref,
@@ -419,7 +570,6 @@ export const AttackPathGraph = ({
419570
isFilteredView={isFilteredView}
420571
initialNodeId={initialNodeId}
421572
expandedResources={expandedResources}
422-
onResourceToggle={onResourceToggle}
423573
onNodeClick={onNodeClick}
424574
onInitialFilter={onInitialFilter}
425575
/>

0 commit comments

Comments
 (0)