mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
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:
+46
-33
@@ -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 ?? [];
|
||||
|
||||
+11
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user