mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
feat(attack-paths): add filtered view for graph nodes (#9784)
This commit is contained in:
+100
-52
@@ -37,6 +37,7 @@ interface AttackPathGraphProps {
|
||||
data: AttackPathGraphData;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
selectedNodeId?: string | null;
|
||||
isFilteredView?: boolean;
|
||||
ref?: Ref<AttackPathGraphRef>;
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ const HEXAGON_HEIGHT = 55; // Height for finding hexagons
|
||||
const AttackPathGraphComponent = forwardRef<
|
||||
AttackPathGraphRef,
|
||||
AttackPathGraphProps
|
||||
>(({ data, onNodeClick, selectedNodeId }, ref) => {
|
||||
>(({ data, onNodeClick, selectedNodeId, isFilteredView = false }, ref) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const zoomBehaviorRef = useRef<ZoomBehavior<SVGSVGElement, unknown> | null>(
|
||||
@@ -124,12 +125,26 @@ const AttackPathGraphComponent = forwardRef<
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
const hasFindings = resourcesWithFindingsRef.current.has(d.id);
|
||||
const isSelected = d.id === selectedNodeId;
|
||||
|
||||
// Resources with findings keep their wider stroke
|
||||
if (!isFinding && hasFindings) {
|
||||
return 2.5;
|
||||
}
|
||||
return d.id === selectedNodeId ? 3 : isFinding ? 2 : 1.5;
|
||||
if (isSelected) return 4;
|
||||
if (!isFinding && hasFindings) return 2.5;
|
||||
return isFinding ? 2 : 1.5;
|
||||
})
|
||||
.attr("filter", (d: NodeData) => {
|
||||
const isFinding = d.data.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
const hasFindings = resourcesWithFindingsRef.current.has(d.id);
|
||||
const isSelected = d.id === selectedNodeId;
|
||||
|
||||
if (isSelected) return "url(#selectedGlow)";
|
||||
if (!isFinding && hasFindings) return "url(#redGlow)";
|
||||
return isFinding ? "url(#glow)" : null;
|
||||
})
|
||||
.attr("class", (d: NodeData) => {
|
||||
const isSelected = d.id === selectedNodeId;
|
||||
return isSelected ? "node-shape selected-node" : "node-shape";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,16 +260,19 @@ const AttackPathGraphComponent = forwardRef<
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Initially hide finding nodes
|
||||
// Initially hide finding nodes - they are shown when user clicks on a node
|
||||
// In filtered view, show all nodes since they're already filtered to the selected path
|
||||
const initialHiddenNodes = new Set<string>();
|
||||
data.nodes.forEach((node) => {
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
if (isFinding) {
|
||||
initialHiddenNodes.add(node.id);
|
||||
}
|
||||
});
|
||||
if (!isFilteredView) {
|
||||
data.nodes.forEach((node) => {
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
if (isFinding) {
|
||||
initialHiddenNodes.add(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
hiddenNodeIdsRef.current = initialHiddenNodes;
|
||||
|
||||
// Create a map to store original node data
|
||||
@@ -373,6 +391,16 @@ const AttackPathGraphComponent = forwardRef<
|
||||
.attr("flood-color", GRAPH_ALERT_BORDER_COLOR)
|
||||
.attr("flood-opacity", "0.6");
|
||||
|
||||
// Orange glow filter for selected/filtered node
|
||||
const selectedGlowFilter = defs.append("filter").attr("id", "selectedGlow");
|
||||
selectedGlowFilter
|
||||
.append("feDropShadow")
|
||||
.attr("dx", "0")
|
||||
.attr("dy", "0")
|
||||
.attr("stdDeviation", "6")
|
||||
.attr("flood-color", GRAPH_EDGE_HIGHLIGHT_COLOR)
|
||||
.attr("flood-opacity", "0.8");
|
||||
|
||||
// Arrow marker (default white) - refX=10 places the arrow tip exactly at the line endpoint
|
||||
defs
|
||||
.append("marker")
|
||||
@@ -401,7 +429,7 @@ const AttackPathGraphComponent = forwardRef<
|
||||
.attr("d", "M 0 0 L 10 5 L 0 10 z")
|
||||
.attr("fill", GRAPH_EDGE_HIGHLIGHT_COLOR);
|
||||
|
||||
// Add CSS animation for dashed lines and resource edge styles
|
||||
// Add CSS animation for dashed lines, resource edge styles, and selected node pulse
|
||||
svg.append("style").text(`
|
||||
@keyframes dash {
|
||||
to {
|
||||
@@ -414,6 +442,20 @@ const AttackPathGraphComponent = forwardRef<
|
||||
.resource-edge {
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
@keyframes selectedPulse {
|
||||
0%, 100% {
|
||||
stroke-opacity: 1;
|
||||
stroke-width: 4px;
|
||||
}
|
||||
50% {
|
||||
stroke-opacity: 0.6;
|
||||
stroke-width: 6px;
|
||||
}
|
||||
}
|
||||
.selected-node {
|
||||
animation: selectedPulse 1.2s ease-in-out infinite;
|
||||
filter: url(#selectedGlow);
|
||||
}
|
||||
`);
|
||||
|
||||
const linkGroup = container.append("g").attr("class", "links");
|
||||
@@ -510,20 +552,18 @@ const AttackPathGraphComponent = forwardRef<
|
||||
return hasFinding ? "animated-edge" : "resource-edge";
|
||||
})
|
||||
.attr("marker-end", "url(#arrowhead)")
|
||||
.each(function (d) {
|
||||
// Resource-to-resource edges are ALWAYS visible
|
||||
// Finding edges are only visible when the finding node is visible
|
||||
.style("visibility", (d) => {
|
||||
const sourceIsFinding = isNodeFinding(d.sourceId);
|
||||
const targetIsFinding = isNodeFinding(d.targetId);
|
||||
|
||||
let visibility = "visible";
|
||||
if (sourceIsFinding || targetIsFinding) {
|
||||
const sourceHidden = hiddenNodeIdsRef.current.has(d.sourceId);
|
||||
const targetHidden = hiddenNodeIdsRef.current.has(d.targetId);
|
||||
visibility = sourceHidden || targetHidden ? "hidden" : "visible";
|
||||
// Hide edges connected to findings in full view (shown when user clicks on a node or in filtered view)
|
||||
if (
|
||||
!isFilteredView &&
|
||||
(sourceIsFinding || targetIsFinding)
|
||||
) {
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
select(this).style("visibility", visibility);
|
||||
return "visible";
|
||||
});
|
||||
|
||||
// Store linkElements reference for hover interactions
|
||||
@@ -553,9 +593,10 @@ const AttackPathGraphComponent = forwardRef<
|
||||
.attr("class", "node")
|
||||
.attr("transform", (d) => `translate(${d.x},${d.y})`)
|
||||
.attr("cursor", "pointer")
|
||||
.style("display", (d) =>
|
||||
hiddenNodeIdsRef.current.has(d.id) ? "none" : null,
|
||||
)
|
||||
.style("display", (d) => {
|
||||
// Hide findings in full view (they are shown when user clicks on a node or in filtered view)
|
||||
return hiddenNodeIdsRef.current.has(d.id) ? "none" : null;
|
||||
})
|
||||
.on("mouseenter", function (_event: PointerEvent, d) {
|
||||
// Highlight entire path from this node
|
||||
const pathEdges = getPathEdges(d.id, edgesData);
|
||||
@@ -852,31 +893,41 @@ const AttackPathGraphComponent = forwardRef<
|
||||
L ${-w / 2} 0
|
||||
Z
|
||||
`;
|
||||
const isSelected = d.id === selectedNodeId;
|
||||
group
|
||||
.append("path")
|
||||
.attr("d", hexPath)
|
||||
.attr("fill", nodeColor)
|
||||
.attr("fill-opacity", 0.85)
|
||||
.attr(
|
||||
"stroke",
|
||||
d.id === selectedNodeId ? GRAPH_SELECTION_COLOR : borderColor,
|
||||
)
|
||||
.attr("stroke-width", d.id === selectedNodeId ? 3 : 2)
|
||||
.attr("filter", "url(#glow)")
|
||||
.attr("class", "node-shape");
|
||||
.attr("stroke", isSelected ? GRAPH_EDGE_HIGHLIGHT_COLOR : borderColor)
|
||||
.attr("stroke-width", isSelected ? 4 : 2)
|
||||
.attr("filter", isSelected ? "url(#selectedGlow)" : "url(#glow)")
|
||||
.attr("class", isSelected ? "node-shape selected-node" : "node-shape");
|
||||
} else {
|
||||
// Check if this is an Internet node
|
||||
const isInternet = d.data.labels.some(
|
||||
(label) => label.toLowerCase() === "internet",
|
||||
);
|
||||
|
||||
const isSelected = d.id === selectedNodeId;
|
||||
|
||||
// Resources with findings get red border and red glow (even when selected)
|
||||
// Selected nodes get orange border
|
||||
const strokeColor = hasFindings
|
||||
? GRAPH_ALERT_BORDER_COLOR
|
||||
: d.id === selectedNodeId
|
||||
? GRAPH_SELECTION_COLOR
|
||||
: isSelected
|
||||
? GRAPH_EDGE_HIGHLIGHT_COLOR
|
||||
: borderColor;
|
||||
|
||||
// Determine filter: selected takes priority, then hasFindings, then default
|
||||
const nodeFilter = isSelected
|
||||
? "url(#selectedGlow)"
|
||||
: hasFindings
|
||||
? "url(#redGlow)"
|
||||
: "url(#glow)";
|
||||
|
||||
const nodeClass = isSelected ? "node-shape selected-node" : "node-shape";
|
||||
|
||||
if (isInternet) {
|
||||
// Globe shape for Internet nodes - larger than regular nodes
|
||||
const radius = NODE_HEIGHT * 0.8;
|
||||
@@ -890,12 +941,9 @@ const AttackPathGraphComponent = forwardRef<
|
||||
.attr("fill", nodeColor)
|
||||
.attr("fill-opacity", 0.85)
|
||||
.attr("stroke", strokeColor)
|
||||
.attr(
|
||||
"stroke-width",
|
||||
hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5,
|
||||
)
|
||||
.attr("filter", hasFindings ? "url(#redGlow)" : "url(#glow)")
|
||||
.attr("class", "node-shape");
|
||||
.attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5)
|
||||
.attr("filter", nodeFilter)
|
||||
.attr("class", nodeClass);
|
||||
|
||||
// Horizontal ellipse (equator)
|
||||
group
|
||||
@@ -933,17 +981,14 @@ const AttackPathGraphComponent = forwardRef<
|
||||
.attr("fill", nodeColor)
|
||||
.attr("fill-opacity", 0.85)
|
||||
.attr("stroke", strokeColor)
|
||||
.attr(
|
||||
"stroke-width",
|
||||
hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5,
|
||||
)
|
||||
.attr("filter", hasFindings ? "url(#redGlow)" : null)
|
||||
.attr("class", "node-shape");
|
||||
.attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5)
|
||||
.attr("filter", nodeFilter)
|
||||
.attr("class", nodeClass);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store reference for updating selection later (select all shapes)
|
||||
// Store references for updating selection later
|
||||
const nodeShapes = nodeElements.selectAll(".node-shape");
|
||||
nodeShapesRef.current = nodeShapes as unknown as ReturnType<
|
||||
typeof select<SVGRectElement, NodeData>
|
||||
@@ -1100,8 +1145,11 @@ const AttackPathGraphComponent = forwardRef<
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
// D3's imperative rendering model requires controlled re-renders.
|
||||
// We intentionally only re-render on data/view changes, not on callback refs
|
||||
// (onNodeClick, selectedNodeId) which would cause unnecessary D3 re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
}, [data, isFilteredView]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -8,17 +8,31 @@ import type {
|
||||
GraphState,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
interface GraphStore extends GraphState {
|
||||
import { computeFilteredSubgraph } from "../_lib";
|
||||
|
||||
interface FilteredViewState {
|
||||
isFilteredView: boolean;
|
||||
filteredNodeId: string | null;
|
||||
fullData: AttackPathGraphData | null; // Original data before filtering
|
||||
}
|
||||
|
||||
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,
|
||||
filteredData: AttackPathGraphData | null,
|
||||
fullData: AttackPathGraphData | null,
|
||||
) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: GraphState = {
|
||||
const initialState: GraphState & FilteredViewState = {
|
||||
data: null,
|
||||
selectedNodeId: null,
|
||||
loading: false,
|
||||
@@ -26,22 +40,27 @@ const initialState: GraphState = {
|
||||
zoomLevel: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
isFilteredView: false,
|
||||
filteredNodeId: null,
|
||||
fullData: null,
|
||||
};
|
||||
|
||||
const useGraphStore = create<GraphStore>((set) => ({
|
||||
...initialState,
|
||||
setGraphData: (data) => set({ data, error: null }),
|
||||
setGraphData: (data) => set({ data, fullData: null, error: null, isFilteredView: false, filteredNodeId: null }),
|
||||
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setZoom: (zoomLevel) => set({ zoomLevel }),
|
||||
setPan: (panX, panY) => set({ panX, panY }),
|
||||
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
|
||||
set({ isFilteredView: isFiltered, filteredNodeId: nodeId, data: filteredData, fullData, selectedNodeId: nodeId }),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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();
|
||||
@@ -86,10 +105,46 @@ export const useGraphState = () => {
|
||||
const clearGraph = () => {
|
||||
store.setGraphData({ nodes: [], edges: [] });
|
||||
store.setSelectedNodeId(null);
|
||||
store.setFilteredView(false, null, null, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter filtered view mode - redraws graph with only the selected path
|
||||
* Stores full data so we can restore it when exiting filtered view
|
||||
*/
|
||||
const enterFilteredView = (nodeId: string) => {
|
||||
if (!store.data) return;
|
||||
|
||||
// Use fullData if we're already in filtered view, otherwise use current data
|
||||
const sourceData = store.fullData || store.data;
|
||||
const filteredData = computeFilteredSubgraph(sourceData, nodeId);
|
||||
store.setFilteredView(true, nodeId, filteredData, sourceData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Exit filtered view mode - restore full graph data
|
||||
*/
|
||||
const exitFilteredView = () => {
|
||||
if (!store.isFilteredView || !store.fullData) return;
|
||||
store.setFilteredView(false, null, store.fullData, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the node that was used to filter the view
|
||||
*/
|
||||
const getFilteredNode = (): GraphNode | null => {
|
||||
if (!store.isFilteredView || !store.filteredNodeId) return null;
|
||||
// Look in fullData since that's where the original node data is
|
||||
const sourceData = store.fullData || store.data;
|
||||
if (!sourceData) return null;
|
||||
return (
|
||||
sourceData.nodes.find((node) => node.id === store.filteredNodeId) || null
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
data: store.data,
|
||||
fullData: store.fullData,
|
||||
selectedNodeId: store.selectedNodeId,
|
||||
selectedNode: getSelectedNode(),
|
||||
loading: store.loading,
|
||||
@@ -97,6 +152,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 +163,7 @@ export const useGraphState = () => {
|
||||
updateZoomAndPan,
|
||||
resetGraph,
|
||||
clearGraph,
|
||||
enterFilteredView,
|
||||
exitFilteredView,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,11 +2,129 @@
|
||||
* Utility functions for attack path graph operations
|
||||
*/
|
||||
|
||||
import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";
|
||||
|
||||
/**
|
||||
* Type for edge node reference - can be a string ID or an object with id property
|
||||
*/
|
||||
export type EdgeNodeRef = string | { id: string };
|
||||
|
||||
/**
|
||||
* Helper to get edge source/target ID from string or object
|
||||
*/
|
||||
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
|
||||
if (typeof nodeRef === "string") {
|
||||
return nodeRef;
|
||||
}
|
||||
return nodeRef.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute a filtered subgraph containing only the path through the target 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
|
||||
*/
|
||||
export const 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 visibleNodeIds = new Set<string>();
|
||||
visibleNodeIds.add(targetNodeId);
|
||||
|
||||
// Traverse upstream (backward) - find all ancestors
|
||||
const traverseUpstream = (nodeId: string) => {
|
||||
const sources = backwardEdges.get(nodeId);
|
||||
if (sources) {
|
||||
sources.forEach((sourceId) => {
|
||||
if (!visibleNodeIds.has(sourceId)) {
|
||||
visibleNodeIds.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 (!visibleNodeIds.has(targetId)) {
|
||||
visibleNodeIds.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) {
|
||||
visibleNodeIds.add(targetId);
|
||||
}
|
||||
if (targetId === targetNodeId && sourceIsFinding) {
|
||||
visibleNodeIds.add(sourceId);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter nodes and edges to only include visible ones
|
||||
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
|
||||
const filteredEdges = edges.filter((edge) => {
|
||||
const sourceId = getEdgeNodeId(edge.source);
|
||||
const targetId = getEdgeNodeId(edge.target);
|
||||
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: filteredNodes,
|
||||
edges: filteredEdges,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Find edges in the path from a given node.
|
||||
* Upstream: follows only ONE parent path (first parent at each level) to avoid lighting up siblings
|
||||
* Downstream: follows ALL children recursively
|
||||
*
|
||||
* Uses pre-built adjacency maps for O(1) lookups instead of O(n) array searches per traversal step.
|
||||
*
|
||||
* @param nodeId - The starting node ID
|
||||
* @param edges - Array of edges with sourceId and targetId
|
||||
* @returns Set of edge IDs in the format "sourceId-targetId"
|
||||
@@ -15,6 +133,23 @@ export const getPathEdges = (
|
||||
nodeId: string,
|
||||
edges: Array<{ sourceId: string; targetId: string }>,
|
||||
): Set<string> => {
|
||||
// Build adjacency maps once - O(n)
|
||||
const parentMap = new Map<string, { sourceId: string; targetId: string }>();
|
||||
const childrenMap = new Map<
|
||||
string,
|
||||
Array<{ sourceId: string; targetId: string }>
|
||||
>();
|
||||
|
||||
edges.forEach((edge) => {
|
||||
// First parent only (matches original behavior of find())
|
||||
if (!parentMap.has(edge.targetId)) {
|
||||
parentMap.set(edge.targetId, edge);
|
||||
}
|
||||
const children = childrenMap.get(edge.sourceId) || [];
|
||||
children.push(edge);
|
||||
childrenMap.set(edge.sourceId, children);
|
||||
});
|
||||
|
||||
const pathEdgeIds = new Set<string>();
|
||||
const visitedNodes = new Set<string>();
|
||||
|
||||
@@ -24,8 +159,7 @@ export const getPathEdges = (
|
||||
if (visitedNodes.has(`up-${currentNodeId}`)) return;
|
||||
visitedNodes.add(`up-${currentNodeId}`);
|
||||
|
||||
// Find the first parent edge only
|
||||
const parentEdge = edges.find((edge) => edge.targetId === currentNodeId);
|
||||
const parentEdge = parentMap.get(currentNodeId); // O(1) lookup
|
||||
if (parentEdge) {
|
||||
pathEdgeIds.add(`${parentEdge.sourceId}-${parentEdge.targetId}`);
|
||||
traverseUpstream(parentEdge.sourceId);
|
||||
@@ -37,11 +171,10 @@ export const getPathEdges = (
|
||||
if (visitedNodes.has(`down-${currentNodeId}`)) return;
|
||||
visitedNodes.add(`down-${currentNodeId}`);
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (edge.sourceId === currentNodeId) {
|
||||
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
|
||||
traverseDownstream(edge.targetId);
|
||||
}
|
||||
const children = childrenMap.get(currentNodeId) || []; // O(1) lookup
|
||||
children.forEach((edge) => {
|
||||
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
|
||||
traverseDownstream(edge.targetId);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ export {
|
||||
exportGraphAsSVG,
|
||||
} from "./export";
|
||||
export { formatNodeLabel, formatNodeLabels } from "./format";
|
||||
export { getPathEdges } from "./graph-utils";
|
||||
export {
|
||||
computeFilteredSubgraph,
|
||||
getEdgeNodeId,
|
||||
getPathEdges,
|
||||
type EdgeNodeRef,
|
||||
} from "./graph-utils";
|
||||
export {
|
||||
getNodeBorderColor,
|
||||
getNodeColor,
|
||||
|
||||
@@ -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">
|
||||
@@ -441,6 +477,7 @@ export default function AttackPathAnalysisPage() {
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
{/* Node Detail Panel - Side by side */}
|
||||
@@ -509,6 +546,7 @@ export default function AttackPathAnalysisPage() {
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user