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 bf2408a76e..3adf5fa5dc 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 @@ -53,11 +53,7 @@ interface AttackPathGraphProps { selectedNodeId?: string | null; isFilteredView?: boolean; initialNodeId?: string; - // Tier 1 expansion state — controlled by the parent (lives in the graph - // store) so it survives filtered-view enter/exit. If omitted, no resource - // expansion is tracked and findings stay hidden in the full view. expandedResources?: Set; - onResourceToggle?: (resourceId: string) => void; onNodeClick?: (node: GraphNode) => void; onInitialFilter?: (filteredData: AttackPathGraphData) => void; ref?: Ref; @@ -182,7 +178,6 @@ const GraphCanvas = ({ isFilteredView, initialNodeId, expandedResources, - onResourceToggle, onNodeClick, onInitialFilter, ref, @@ -272,23 +267,40 @@ const GraphCanvas = ({ } if (previousFilteredRef.current === isFilteredView) return; previousFilteredRef.current = isFilteredView; - // Defer to the next animation frame so React Flow has applied the new - // layout (after the filter swap) before we measure for the fit. - const frame = requestAnimationFrame(() => { - fitView(AUTO_FIT_OPTIONS); - }); + // React Flow measures node sizes asynchronously via ResizeObserver after + // the data swap. A single rAF runs while measured.width is still 0, so + // fitView computes a degenerate bbox and the viewport keeps the user's + // 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); - }, [isFilteredView, fitView]); + }, [isFilteredView, fitView, getNodes]); useEffect(() => { const previous = previousExpandedRef.current; previousExpandedRef.current = expanded; if (previous === expanded) return; - // Only fit on growth — collapsing intentionally leaves the user's - // framing alone. const newResourceIds = Array.from(expanded).filter( (id) => !previous.has(id), ); + // Only fit on growth — collapsing intentionally leaves the user's + // current framing alone. if (newResourceIds.length === 0) return; const newFindingIds = new Set(); @@ -299,26 +311,42 @@ const GraphCanvas = ({ } if (newFindingIds.size === 0) return; - const frame = requestAnimationFrame(() => { - const containerEl = containerRef.current; - if (!containerEl) return; - const { width, height } = containerEl.getBoundingClientRect(); - if (width === 0 || height === 0) return; - const { x, y, zoom } = getViewport(); - const minX = -x / zoom; - const minY = -y / zoom; - const maxX = minX + width / zoom; - const maxY = minY + height / zoom; - const anyOutside = getNodes().some((node) => { - if (!newFindingIds.has(node.id)) return false; - const nx = node.position.x; - const ny = node.position.y; - const nw = node.measured?.width ?? 0; - const nh = node.measured?.height ?? 0; - return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY; - }); - if (anyOutside) fitView(AUTO_FIT_OPTIONS); - }); + // Findings transition from hidden to visible on expand, and React Flow + // 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) { + const containerEl = containerRef.current; + if (!containerEl) return; + const { width, height } = containerEl.getBoundingClientRect(); + if (width === 0 || height === 0) return; + const { x, y, zoom } = getViewport(); + const minX = -x / zoom; + const minY = -y / zoom; + const maxX = minX + width / zoom; + const maxY = minY + height / zoom; + const anyOutside = targets.some((node) => { + const nx = node.position.x; + const ny = node.position.y; + const nw = node.measured?.width ?? 0; + const nh = node.measured?.height ?? 0; + 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]); @@ -434,13 +462,6 @@ const GraphCanvas = ({ const handleNodeClick = (_event: MouseEvent, node: Node) => { const graphNode = (node.data as { graphNode: GraphNode }).graphNode; - // Tier 1: clicking resource in full view toggles connected findings - if (!isFilteredView && !isFindingNode(graphNode.labels)) { - if (resourcesWithFindings.has(node.id)) { - onResourceToggle?.(node.id); - } - } - // Always fire parent callback (handles selection + Tier 2 filtered view) onNodeClick?.(graphNode); }; @@ -465,10 +486,14 @@ const GraphCanvas = ({ onNodeMouseLeave={handleNodeMouseLeave} fitView fitViewOptions={{ padding: 0.2, includeHiddenNodes: true }} - zoomOnScroll={false} + // Supported React Flow behavior: wheel over the graph zooms the + // viewport. The surrounding UX avoids using node details as inline + // content, so this no longer fights a below-graph details section. + zoomOnScroll={true} zoomOnPinch={true} zoomOnDoubleClick={false} panOnScroll={false} + preventScrolling={true} minZoom={0.1} maxZoom={10} proOptions={{ hideAttribution: true }} @@ -508,7 +533,6 @@ export const AttackPathGraph = ({ isFilteredView, initialNodeId, expandedResources, - onResourceToggle, onNodeClick, onInitialFilter, ref, @@ -533,7 +557,6 @@ export const AttackPathGraph = ({ isFilteredView={isFilteredView} initialNodeId={initialNodeId} expandedResources={expandedResources} - onResourceToggle={onResourceToggle} onNodeClick={onNodeClick} onInitialFilter={onInitialFilter} /> diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index e22671cfe1..c8c70dc90c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -212,6 +212,104 @@ describe("exploring the graph", () => { expect(graph.isInFilteredView).toBe(false); await graph.clickFirstFindingNode(); expect(graph.isInFilteredView).toBe(true); + expect(graph.hasNodeDetailsModal).toBe(false); + }); + + test("clicking a resource with findings opens the action selector", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.hasNodeDetailsModal).toBe(false); + + await graph.clickFirstResourceNode(); + + expect(graph.hasNodeActionDialog).toBe(true); + expect(graph.hasNodeDetailsModal).toBe(false); + }); + + test("choosing Show findings reveals related finding nodes", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.clickFirstResourceNode(); + await graph.chooseShowFindingsAction(); + + expect(graph.findingNodes.length).toBeGreaterThan(0); + expect(graph.hasNodeDetailsModal).toBe(false); + }); + + test("expanded resources offer Hide findings in the action selector", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.clickFirstResourceNode(); + await graph.chooseShowFindingsAction(); + expect(graph.findingNodes.length).toBeGreaterThan(0); + + await graph.clickFirstResourceNode(); + + expect(graph.hasNodeActionDialog).toBe(true); + expect(graph.containsText(/Hide findings/i)).toBe(true); + + await graph.chooseHideFindingsAction(); + + expect(graph.findingNodes.length).toBe(0); + }); + + test("choosing View node details opens node details in a modal", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.clickFirstResourceNode(); + await graph.chooseViewNodeDetailsAction(); + + expect(graph.hasNodeDetailsModal).toBe(true); + }); + + test("clicking a resource without findings opens node details in a modal", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.hasNodeDetailsModal).toBe(false); + + await graph.clickFirstResourceNodeWithoutFindings(); + + expect(graph.hasNodeDetailsModal).toBe(true); + }); + + test("clicking a parent node in filtered view asks whether to go back or view details", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + await graph.clickFirstFindingNode(); + expect(graph.isInFilteredView).toBe(true); + + await graph.clickFirstResourceNode(); + + expect(graph.hasNodeActionDialog).toBe(true); + expect(graph.containsText(/Back to full graph/i)).toBe(true); + + await graph.chooseBackToFullGraphAction(); + + expect(graph.isInFilteredView).toBe(false); }); test("exiting the filtered view restores the full graph", async ({ @@ -294,6 +392,14 @@ describe("auto-fitting the viewport", () => { await graph.executeQuery(); await graph.waitForLayoutStable(3); + // Given - zoom into the current overview so newly revealed findings can + // sit entirely outside the current frame. The expand auto-fit should then + // recover the user instead of leaving them hunting off-screen. + for (let i = 0; i < 5; i++) { + await graph.zoomIn(); + await graph.waitForTransition(80); + } + // Hidden findings are not measured by the initial declarative fit, so // their positions can sit outside the framed viewport. Expanding the // resources should re-fit so the user does not have to hunt for the 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 77436b5d69..010de04784 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 @@ -231,6 +231,23 @@ export class AttackPathPageHarness { return document.querySelector('[role="dialog"]'); } + get nodeDetailsHeading(): HTMLElement | null { + return ( + Array.from( + document.querySelectorAll("[role='dialog'] h2"), + ).find((heading) => /^Node Details$/i.test(heading.textContent ?? "")) ?? + null + ); + } + + get hasNodeDetailsModal(): boolean { + return !!this.nodeDetailsHeading; + } + + get hasNodeActionDialog(): boolean { + return this.containsText(/Choose node action/i); + } + // --- Sync helpers --- /** Wait until React Flow has rendered at least `expected` node elements. */ @@ -341,6 +358,33 @@ export class AttackPathPageHarness { return resource; } + async clickFirstResourceNodeWithoutFindings(): Promise { + const findingIds = new Set( + (this.fixture.queryResult?.nodes ?? []) + .filter((n) => + n.labels.some((l) => l.toLowerCase().includes("finding")), + ) + .map((n) => n.id), + ); + const resourceWithFindingIds = new Set(); + for (const rel of this.fixture.queryResult?.relationships ?? []) { + if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target); + if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source); + } + const resource = this.resourceNodes.find((node) => { + const id = node.getAttribute("data-id"); + return id && !resourceWithFindingIds.has(id); + }); + if (!resource) { + throw new Error( + "clickFirstResourceNodeWithoutFindings: no resource without findings rendered", + ); + } + await this.user.click(resource); + await this.waitForTransition(); + return resource; + } + /** * Click the first finding `times` times back-to-back, with no transition * waits between clicks. Used for rapid-click race tests. @@ -399,6 +443,9 @@ export class AttackPathPageHarness { if (el) { await this.user.click(el); await this.waitForTransition(50); + if (this.hasNodeActionDialog) { + await this.chooseShowFindingsAction(); + } } } } @@ -444,6 +491,51 @@ export class AttackPathPageHarness { await this.user.click(btn); } + async closeNodeDetailsModal(): Promise { + const btn = this.q('button[aria-label="Close node details"]'); + if (!btn) throw new Error("closeNodeDetailsModal: modal not rendered"); + await this.user.click(btn); + await this.waitForTransition(); + } + + async chooseShowFindingsAction(): Promise { + const button = Array.from( + document.querySelectorAll("button"), + ).find((btn) => /show findings/i.test(btn.textContent ?? "")); + if (!button) throw new Error("chooseShowFindingsAction: button not found"); + await this.user.click(button); + await this.waitForTransition(); + } + + async chooseHideFindingsAction(): Promise { + const button = Array.from( + document.querySelectorAll("button"), + ).find((btn) => /hide findings/i.test(btn.textContent ?? "")); + if (!button) throw new Error("chooseHideFindingsAction: button not found"); + await this.user.click(button); + await this.waitForTransition(); + } + + async chooseViewNodeDetailsAction(): Promise { + const button = Array.from( + document.querySelectorAll("button"), + ).find((btn) => /view node details/i.test(btn.textContent ?? "")); + if (!button) + throw new Error("chooseViewNodeDetailsAction: button not found"); + await this.user.click(button); + await this.waitForTransition(); + } + + async chooseBackToFullGraphAction(): Promise { + const button = Array.from( + document.querySelectorAll("button"), + ).find((btn) => /back to full graph/i.test(btn.textContent ?? "")); + if (!button) + throw new Error("chooseBackToFullGraphAction: button not found"); + await this.user.click(button); + await this.waitForTransition(); + } + async exitFilteredView(): Promise { const btn = this.toolbar.backToFullViewButton; if (!btn) throw new Error("exitFilteredView: not in filtered view"); 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 f8474ac3ee..dbbba9fdd9 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 @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, Info, Maximize2, X } from "lucide-react"; +import { ArrowLeft, Info, Maximize2 } from "lucide-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; @@ -22,8 +22,6 @@ import { AlertDescription, AlertTitle, Button, - Card, - CardContent, } from "@/components/shadcn"; import { Dialog, @@ -33,9 +31,8 @@ import { DialogTitle, DialogTrigger, } from "@/components/shadcn/dialog"; -import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { Modal } from "@/components/shadcn/modal/modal"; import { useToast } from "@/components/ui"; -import { cn } from "@/lib/utils"; import type { AttackPathQuery, AttackPathQueryError, @@ -50,7 +47,7 @@ import { GraphControls, GraphLegend, GraphLoading, - NodeDetailContent, + NodeDetailPanel as NodeDetailDrawer, QueryDescription, QueryExecutionError, QueryParametersForm, @@ -62,98 +59,13 @@ import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; import { exportGraphAsPNG } from "./_lib"; -const getNodeDisplayTitle = (node: GraphNode): string => { - const isFinding = node.labels.some((l) => - l.toLowerCase().includes("finding"), - ); - return String( - isFinding - ? node.properties?.check_title || node.properties?.id || "Unknown Finding" - : node.properties?.name || node.properties?.id || "Unknown Resource", - ); -}; +const NODE_ACTION_CONTEXT = { + RESOURCE_FINDINGS: "resource-findings", + FILTERED_PARENT: "filtered-parent", +} as const; -interface NodeDetailPanelProps { - node: GraphNode; - allNodes: GraphNode[]; - onClose: () => void; - headingId: string; - compact?: boolean; - onViewFinding?: (findingId: string) => void; - viewFindingLoading?: boolean; -} - -const NodeDetailPanel = ({ - node, - allNodes, - onClose, - headingId, - compact, - onViewFinding, - viewFindingLoading = false, -}: NodeDetailPanelProps) => { - const isFinding = node.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const findingId = String(node.properties?.id || node.id); - - return ( - <> -
-
-

- Node Details -

-

- {getNodeDisplayTitle(node)} -

-
-
- {!compact && isFinding && onViewFinding && ( - - )} - -
-
- - - ); -}; +type NodeActionContext = + (typeof NODE_ACTION_CONTEXT)[keyof typeof NODE_ACTION_CONTEXT]; /** * Attack Paths @@ -171,10 +83,13 @@ 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 graphRef = useRef(null); const fullscreenGraphRef = useRef(null); const hasResetRef = useRef(false); - const nodeDetailsRef = useRef(null); const graphContainerRef = useRef(null); const [queries, setQueries] = useState([]); @@ -385,27 +300,46 @@ export default function AttackPathsPage() { }; const handleNodeClick = (node: GraphNode) => { - // Always select the node (opens detail panel) - graphState.selectNode(node.id); - const isFinding = node.labels.some((label) => label.toLowerCase().includes("finding"), ); - // Tier 2: clicking a finding node OR any node in filtered view → enter filtered view - if (isFinding || graphState.isFilteredView) { + if (isFinding) { + // Findings skip the intermediate node-details modal. The finding drawer + // is the useful destination, so open it directly from the graph click. graphState.enterFilteredView(node.id); + // enterFilteredView stores the filtered node as selected so the graph can + // highlight it. Clear the selection right after for findings so the node + // details modal does not open before the finding drawer. + graphState.selectNode(null); + handleViewFinding(String(node.properties?.id || node.id)); + return; } - // Scroll to details section for findings - if (isFinding) { - setTimeout(() => { - nodeDetailsRef.current?.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - }, 100); + if (graphState.isFilteredView) { + setActionNode(node); + setActionContext(NODE_ACTION_CONTEXT.FILTERED_PARENT); + return; } + + const sourceData = graphState.fullData || graphState.data; + const hasFindings = sourceData?.edges?.some((edge) => { + if (edge.source !== node.id && edge.target !== node.id) return false; + const otherId = edge.source === node.id ? edge.target : edge.source; + const otherNode = sourceData.nodes?.find(({ id }) => id === otherId); + return otherNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + }); + + if (hasFindings) { + setActionNode(node); + setActionContext(NODE_ACTION_CONTEXT.RESOURCE_FINDINGS); + return; + } + + finding.resetFindingDetails(); + graphState.selectNode(node.id); }; const handleBackToFullView = () => { @@ -416,11 +350,34 @@ export default function AttackPathsPage() { graphState.selectNode(null); }; + const handleShowNodeFindings = () => { + if (!actionNode) return; + graphState.toggleExpandedResource(actionNode.id); + setActionNode(null); + }; + + const handleOpenNodeDetails = () => { + if (!actionNode) return; + finding.resetFindingDetails(); + graphState.selectNode(actionNode.id); + setActionNode(null); + }; + + const handleReturnToFullGraph = () => { + graphState.exitFilteredView(); + graphState.selectNode(null); + setActionNode(null); + }; + const handleViewFinding = (findingId: string) => { if (!findingId) return; void finding.navigateToFinding(findingId); }; + const actionNodeFindingsExpanded = actionNode + ? graphState.expandedResources.has(actionNode.id) + : false; + const handleGraphExport = async (target: "main" | "fullscreen") => { const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef; const handle = ref.current; @@ -674,34 +631,8 @@ export default function AttackPathsPage() { expandedResources={ graphState.expandedResources } - onResourceToggle={ - graphState.toggleExpandedResource - } /> - {/* Node Detail Panel - Side by side */} - {graphState.selectedNode && graphState.data && ( -
- - - - - -
- )} @@ -721,7 +652,6 @@ export default function AttackPathsPage() { selectedNodeId={graphState.selectedNodeId} isFilteredView={graphState.isFilteredView} expandedResources={graphState.expandedResources} - onResourceToggle={graphState.toggleExpandedResource} /> @@ -734,21 +664,48 @@ export default function AttackPathsPage() { )} - {/* Node Detail Panel - Below Graph */} - {graphState.selectedNode && graphState.data && ( -
+ )} + + {actionNode && ( + { + if (!open) setActionNode(null); + }} + size="md" + title="Choose node action" + description={ + actionContext === 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." + } > - -
+
+ + {actionContext === NODE_ACTION_CONTEXT.FILTERED_PARENT ? ( + + ) : ( + + )} +
+ )} {finding.findingDetails && (