From aa311623fe7788c4f503beae8337a448f6f6a6a8 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Tue, 5 May 2026 19:25:00 +0200 Subject: [PATCH] refactor(ui): simplify attack paths graph interactions - Reuse shared measured-fit scheduling for graph viewport updates - Consolidate node action dialog state - Tighten browser harness dialog detection --- .../_components/graph/attack-path-graph.tsx | 79 +++++++++++-------- .../attack-paths-page.harness.ts | 12 ++- .../query-builder/attack-paths-page.tsx | 54 +++++++------ 3 files changed, 89 insertions(+), 56 deletions(-) diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx index 3adf5fa5dc..4e2fd7eef0 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -172,6 +172,30 @@ const AUTO_FIT_OPTIONS = { 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, @@ -273,23 +297,16 @@ const GraphCanvas = ({ // 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). - let frame = 0; - let attempts = 0; - const MAX_ATTEMPTS = 30; - const tryFit = () => { - const visibleNodes = getNodes().filter((n) => !n.hidden); - const allMeasured = - visibleNodes.length > 0 && - visibleNodes.every((n) => (n.measured?.width ?? 0) > 0); - if (allMeasured || attempts >= MAX_ATTEMPTS) { - fitView(AUTO_FIT_OPTIONS); - return; - } - attempts += 1; - frame = requestAnimationFrame(tryFit); - }; - frame = requestAnimationFrame(tryFit); - return () => cancelAnimationFrame(frame); + 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), + ); }, [isFilteredView, fitView, getNodes]); useEffect(() => { @@ -315,15 +332,16 @@ const GraphCanvas = ({ // measures them asynchronously. Poll before checking whether their full // bounding boxes sit entirely past a viewport edge; collapsing and // partially clipped findings preserve the user's current frame. - let frame = 0; - let attempts = 0; - const MAX_ATTEMPTS = 30; - const tryFit = () => { - const targets = getNodes().filter((n) => newFindingIds.has(n.id)); - const allMeasured = - targets.length === newFindingIds.size && - targets.every((n) => (n.measured?.width ?? 0) > 0); - if (allMeasured || attempts >= MAX_ATTEMPTS) { + 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 targets = getNodes().filter((n) => newFindingIds.has(n.id)); const containerEl = containerRef.current; if (!containerEl) return; const { width, height } = containerEl.getBoundingClientRect(); @@ -341,13 +359,8 @@ const GraphCanvas = ({ return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY; }); if (anyOutside) fitView(AUTO_FIT_OPTIONS); - return; - } - attempts += 1; - frame = requestAnimationFrame(tryFit); - }; - frame = requestAnimationFrame(tryFit); - return () => cancelAnimationFrame(frame); + }, + ); }, [expanded, fitView, getNodes, getViewport]); const nodes = effectiveData.nodes ?? []; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts index 010de04784..c6a6479279 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -244,8 +244,18 @@ export class AttackPathPageHarness { return !!this.nodeDetailsHeading; } + get nodeActionHeading(): HTMLElement | null { + return ( + Array.from( + document.querySelectorAll("[role='dialog'] h2"), + ).find((heading) => + /^Choose node action$/i.test(heading.textContent ?? ""), + ) ?? null + ); + } + get hasNodeActionDialog(): boolean { - return this.containsText(/Choose node action/i); + return !!this.nodeActionHeading; } // --- Sync helpers --- diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index dbbba9fdd9..8b9cb29c13 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -67,6 +67,21 @@ const NODE_ACTION_CONTEXT = { type NodeActionContext = (typeof NODE_ACTION_CONTEXT)[keyof typeof NODE_ACTION_CONTEXT]; +interface NodeActionBase { + node: GraphNode; + context: NodeActionContext; +} + +interface ResourceFindingsNodeAction extends NodeActionBase { + context: typeof NODE_ACTION_CONTEXT.RESOURCE_FINDINGS; +} + +interface FilteredParentNodeAction extends NodeActionBase { + context: typeof NODE_ACTION_CONTEXT.FILTERED_PARENT; +} + +type NodeActionState = ResourceFindingsNodeAction | FilteredParentNodeAction; + /** * Attack Paths * Allows users to select a scan, build a query, and visualize the attack path graph @@ -83,10 +98,7 @@ export default function AttackPathsPage() { const [queriesLoading, setQueriesLoading] = useState(true); const [queriesError, setQueriesError] = useState(null); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); - const [actionNode, setActionNode] = useState(null); - const [actionContext, setActionContext] = useState( - NODE_ACTION_CONTEXT.RESOURCE_FINDINGS, - ); + const [nodeAction, setNodeAction] = useState(null); const graphRef = useRef(null); const fullscreenGraphRef = useRef(null); const hasResetRef = useRef(false); @@ -317,8 +329,7 @@ export default function AttackPathsPage() { } if (graphState.isFilteredView) { - setActionNode(node); - setActionContext(NODE_ACTION_CONTEXT.FILTERED_PARENT); + setNodeAction({ node, context: NODE_ACTION_CONTEXT.FILTERED_PARENT }); return; } @@ -333,8 +344,7 @@ export default function AttackPathsPage() { }); if (hasFindings) { - setActionNode(node); - setActionContext(NODE_ACTION_CONTEXT.RESOURCE_FINDINGS); + setNodeAction({ node, context: NODE_ACTION_CONTEXT.RESOURCE_FINDINGS }); return; } @@ -351,22 +361,22 @@ export default function AttackPathsPage() { }; const handleShowNodeFindings = () => { - if (!actionNode) return; - graphState.toggleExpandedResource(actionNode.id); - setActionNode(null); + if (!nodeAction) return; + graphState.toggleExpandedResource(nodeAction.node.id); + setNodeAction(null); }; const handleOpenNodeDetails = () => { - if (!actionNode) return; + if (!nodeAction) return; finding.resetFindingDetails(); - graphState.selectNode(actionNode.id); - setActionNode(null); + graphState.selectNode(nodeAction.node.id); + setNodeAction(null); }; const handleReturnToFullGraph = () => { graphState.exitFilteredView(); graphState.selectNode(null); - setActionNode(null); + setNodeAction(null); }; const handleViewFinding = (findingId: string) => { @@ -374,8 +384,8 @@ export default function AttackPathsPage() { void finding.navigateToFinding(findingId); }; - const actionNodeFindingsExpanded = actionNode - ? graphState.expandedResources.has(actionNode.id) + const actionNodeFindingsExpanded = nodeAction + ? graphState.expandedResources.has(nodeAction.node.id) : false; const handleGraphExport = async (target: "main" | "fullscreen") => { @@ -675,16 +685,16 @@ export default function AttackPathsPage() { /> )} - {actionNode && ( + {nodeAction && ( { - if (!open) setActionNode(null); + if (!open) setNodeAction(null); }} size="md" title="Choose node action" description={ - actionContext === NODE_ACTION_CONTEXT.FILTERED_PARENT + nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT ? "You're viewing a filtered path. Choose whether to return to the full graph or inspect this node." : "This node has related findings. Choose whether to reveal them in the graph or inspect the node metadata." } @@ -693,7 +703,7 @@ export default function AttackPathsPage() { - {actionContext === NODE_ACTION_CONTEXT.FILTERED_PARENT ? ( + {nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (