feat(attack-paths): add filtered view when clicking on graph nodes

When clicking a node in the attack path graph:
- Filters the graph to show only upstream (ancestors) and downstream (descendants) paths
- Includes findings directly connected to the selected node
- Shows a "Back to Full View" button to restore the complete graph
- Displays an indicator showing which node is being filtered

Uses atomic Zustand state updates to ensure proper re-rendering of the D3 graph.
This commit is contained in:
Andoni A.
2026-01-12 16:33:30 +01:00
parent 422c55404b
commit b071fffe57
2 changed files with 253 additions and 19 deletions
@@ -8,17 +8,34 @@ import type {
GraphState,
} from "@/types/attack-paths";
interface GraphStore extends GraphState {
interface FilteredViewState {
isFilteredView: boolean;
filteredNodeId: string | null;
fullData: AttackPathGraphData | null;
}
interface GraphStore extends GraphState, FilteredViewState {
setGraphData: (data: AttackPathGraphData) => void;
setSelectedNodeId: (nodeId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setZoom: (zoomLevel: number) => void;
setPan: (panX: number, panY: number) => void;
setFilteredView: (
isFiltered: boolean,
nodeId: string | null,
fullData: AttackPathGraphData | null,
) => void;
enterFiltered: (
filteredData: AttackPathGraphData,
nodeId: string,
fullData: AttackPathGraphData,
) => void;
exitFiltered: (fullData: AttackPathGraphData) => void;
reset: () => void;
}
const initialState: GraphState = {
const initialState: GraphState & FilteredViewState = {
data: null,
selectedNodeId: null,
loading: false,
@@ -26,6 +43,9 @@ const initialState: GraphState = {
zoomLevel: 1,
panX: 0,
panY: 0,
isFilteredView: false,
filteredNodeId: null,
fullData: null,
};
const useGraphStore = create<GraphStore>((set) => ({
@@ -36,18 +56,152 @@ const useGraphStore = create<GraphStore>((set) => ({
setError: (error) => set({ error }),
setZoom: (zoomLevel) => set({ zoomLevel }),
setPan: (panX, panY) => set({ panX, panY }),
setFilteredView: (isFiltered, nodeId, fullData) =>
set({ isFilteredView: isFiltered, filteredNodeId: nodeId, fullData }),
// Atomic state update for entering filtered view
enterFiltered: (filteredData, nodeId, fullData) =>
set({
data: filteredData,
isFilteredView: true,
filteredNodeId: nodeId,
fullData: fullData,
selectedNodeId: nodeId,
error: null,
}),
// Atomic state update for exiting filtered view
exitFiltered: (fullData) =>
set({
data: fullData,
isFilteredView: false,
filteredNodeId: null,
fullData: null,
selectedNodeId: null,
}),
reset: () => set(initialState),
}));
/**
* Helper to get edge source/target ID from string or object
*/
function getEdgeNodeId(nodeRef: string | object): string {
if (typeof nodeRef === "string") {
return nodeRef;
}
return (nodeRef as GraphNode).id;
}
/**
* Compute the subgraph containing the upstream and downstream paths for a specific node.
* This follows the directed graph structure of attack paths:
* - Upstream: traces back to the root (AWS Account)
* - Downstream: traces forward to leaf nodes
* - Also includes findings connected to the selected node
*/
function computeFilteredSubgraph(
fullData: AttackPathGraphData,
targetNodeId: string,
): AttackPathGraphData {
const nodes = fullData.nodes;
const edges = fullData.edges || [];
// Build directed adjacency lists
const forwardEdges = new Map<string, Set<string>>(); // source -> targets
const backwardEdges = new Map<string, Set<string>>(); // target -> sources
nodes.forEach((node) => {
forwardEdges.set(node.id, new Set());
backwardEdges.set(node.id, new Set());
});
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
forwardEdges.get(sourceId)?.add(targetId);
backwardEdges.get(targetId)?.add(sourceId);
});
const includedNodes = new Set<string>();
includedNodes.add(targetNodeId);
// Traverse upstream (backward) - find all ancestors
const traverseUpstream = (nodeId: string) => {
const sources = backwardEdges.get(nodeId);
if (sources) {
sources.forEach((sourceId) => {
if (!includedNodes.has(sourceId)) {
includedNodes.add(sourceId);
traverseUpstream(sourceId);
}
});
}
};
// Traverse downstream (forward) - find all descendants
const traverseDownstream = (nodeId: string) => {
const targets = forwardEdges.get(nodeId);
if (targets) {
targets.forEach((targetId) => {
if (!includedNodes.has(targetId)) {
includedNodes.add(targetId);
traverseDownstream(targetId);
}
});
}
};
// Start traversal from the target node
traverseUpstream(targetNodeId);
traverseDownstream(targetNodeId);
// Also include findings directly connected to the selected node
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
const sourceNode = nodes.find((n) => n.id === sourceId);
const targetNode = nodes.find((n) => n.id === targetId);
const sourceIsFinding = sourceNode?.labels.some((l) =>
l.toLowerCase().includes("finding"),
);
const targetIsFinding = targetNode?.labels.some((l) =>
l.toLowerCase().includes("finding"),
);
// Include findings connected to the selected node
if (sourceId === targetNodeId && targetIsFinding) {
includedNodes.add(targetId);
}
if (targetId === targetNodeId && sourceIsFinding) {
includedNodes.add(sourceId);
}
});
// Filter nodes and edges
const filteredNodes = nodes.filter((node) => includedNodes.has(node.id));
const filteredEdges = edges.filter((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
return includedNodes.has(sourceId) && includedNodes.has(targetId);
});
return {
nodes: filteredNodes,
edges: filteredEdges,
};
}
/**
* Custom hook for managing graph visualization state
* Handles graph data, node selection, zoom/pan, and loading states
* Handles graph data, node selection, zoom/pan, loading states, and filtered view
*/
export const useGraphState = () => {
const store = useGraphStore();
// Zustand store methods are stable, no need to memoize
const updateGraphData = (data: AttackPathGraphData) => {
// When updating graph data, exit filtered view if active
if (store.isFilteredView) {
store.setFilteredView(false, null, null);
}
store.setGraphData(data);
};
@@ -86,6 +240,45 @@ export const useGraphState = () => {
const clearGraph = () => {
store.setGraphData({ nodes: [], edges: [] });
store.setSelectedNodeId(null);
store.setFilteredView(false, null, null);
};
/**
* Enter filtered view mode - shows only paths containing the specified node
*/
const enterFilteredView = (nodeId: string) => {
if (!store.data) return;
// Get full data (use stored fullData if already in filtered view, otherwise current data)
const fullData = store.isFilteredView ? store.fullData : store.data;
if (!fullData) return;
const filteredData = computeFilteredSubgraph(fullData, nodeId);
// Atomic state update to ensure graph re-renders with filtered data
store.enterFiltered(filteredData, nodeId, fullData);
};
/**
* Exit filtered view mode - restore the full graph
*/
const exitFilteredView = () => {
if (!store.isFilteredView || !store.fullData) return;
// Atomic state update to restore full graph
store.exitFiltered(store.fullData);
};
/**
* Get the node that was used to filter the view
*/
const getFilteredNode = (): GraphNode | null => {
if (!store.isFilteredView || !store.filteredNodeId || !store.fullData)
return null;
return (
store.fullData.nodes.find((node) => node.id === store.filteredNodeId) ||
null
);
};
return {
@@ -97,6 +290,9 @@ export const useGraphState = () => {
zoomLevel: store.zoomLevel,
panX: store.panX,
panY: store.panY,
isFilteredView: store.isFilteredView,
filteredNodeId: store.filteredNodeId,
filteredNode: getFilteredNode(),
updateGraphData,
selectNode,
startLoading,
@@ -105,5 +301,7 @@ export const useGraphState = () => {
updateZoomAndPan,
resetGraph,
clearGraph,
enterFilteredView,
exitFilteredView,
};
};
@@ -1,6 +1,6 @@
"use client";
import { Maximize2, X } from "lucide-react";
import { ArrowLeft, Maximize2, X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
@@ -233,15 +233,15 @@ export default function AttackPathAnalysisPage() {
};
const handleNodeClick = (node: GraphNode) => {
graphState.selectNode(node.id);
// Enter filtered view showing only paths containing this node
graphState.enterFilteredView(node.id);
// Only scroll to details if it's a finding node
// For findings, also scroll to the details section
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
if (isFinding) {
// Scroll to node details section after a short delay
setTimeout(() => {
nodeDetailsRef.current?.scrollIntoView({
behavior: "smooth",
@@ -251,6 +251,10 @@ export default function AttackPathAnalysisPage() {
}
};
const handleBackToFullView = () => {
graphState.exitFilteredView();
};
const handleCloseDetails = () => {
graphState.selectNode(null);
};
@@ -371,18 +375,50 @@ export default function AttackPathAnalysisPage() {
<>
{/* Info message and controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any resource node to view its related findings
</span>
</div>
{graphState.isFilteredView ? (
<div className="flex items-center gap-3">
<Button
onClick={handleBackToFullView}
variant="outline"
size="sm"
className="gap-2"
aria-label="Return to full graph view"
>
<ArrowLeft size={16} />
Back to Full View
</Button>
<div
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Filtered view active"
>
<span className="flex-shrink-0" aria-hidden="true">
🔍
</span>
<span className="flex-1">
Showing paths for:{" "}
<strong>
{graphState.filteredNode?.properties?.name ||
graphState.filteredNode?.properties?.id ||
"Selected node"}
</strong>
</span>
</div>
</div>
) : (
<div
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected paths
</span>
</div>
)}
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">