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
This commit is contained in:
Alan Buscaglia
2026-05-05 19:25:00 +02:00
parent 142b45a387
commit aa311623fe
3 changed files with 89 additions and 56 deletions
@@ -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 ?? [];
@@ -244,8 +244,18 @@ export class AttackPathPageHarness {
return !!this.nodeDetailsHeading;
}
get nodeActionHeading(): HTMLElement | null {
return (
Array.from(
document.querySelectorAll<HTMLElement>("[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 ---
@@ -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<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const [actionNode, setActionNode] = useState<GraphNode | null>(null);
const [actionContext, setActionContext] = useState<NodeActionContext>(
NODE_ACTION_CONTEXT.RESOURCE_FINDINGS,
);
const [nodeAction, setNodeAction] = useState<NodeActionState | null>(null);
const graphRef = useRef<GraphHandle>(null);
const fullscreenGraphRef = useRef<GraphHandle>(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 && (
<Modal
open={!!actionNode}
open={!!nodeAction}
onOpenChange={(open) => {
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() {
<Button variant="outline" onClick={handleOpenNodeDetails}>
View node details
</Button>
{actionContext === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (
{nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (
<Button onClick={handleReturnToFullGraph}>
Back to full graph
</Button>