mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user