import { useCallback, useMemo, memo } from 'react'; import { ReactFlow, Background, Controls, MiniMap, Node, Edge, NodeProps, Handle, Position, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import dagre from 'dagre'; import { useShallow } from 'zustand/react/shallow'; import { useTopologyStore } from '../../store/topologyStore'; import { getNodeColor, getStatusColor } from '../../utils/colors'; import { Server, Network, Wifi, Box, Database, Folder } from 'lucide-react'; import { NodeType, ServiceCategory } from '../../types'; const nodeIcons: Record = { gateway: , vlan: , wifi: , host_physical: , host_vm: , host_container: , vm_lxc: , vm_qemu: , systemd_service: , service: , volume: , mount: , path: , }; const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) { const highlightPath = useTopologyStore((s) => s.highlightPath); const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string }; const nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category); const statusColor = getStatusColor(nodeData.status || 'unknown'); const isHighlighted = highlightPath.includes(id); const isDimmed = highlightPath.length > 0 && !isHighlighted; return (
{nodeData.label}
{nodeData.ip && (
{nodeData.ip}
)}
); }); const nodeTypes = { custom: CustomNode, }; const nodeWidth = 180; const nodeHeight = 70; function getLayoutedElements(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') { if (nodes.length === 0) return { nodes: [], edges: [] }; const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100 }); nodes.forEach((node) => { dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); }); edges.forEach((edge) => { dagreGraph.setEdge(edge.source, edge.target); }); dagre.layout(dagreGraph); const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); if (!nodeWithPosition) return node; return { ...node, position: { x: nodeWithPosition.x - nodeWidth / 2, y: nodeWithPosition.y - nodeHeight / 2, }, }; }); return { nodes: layoutedNodes, edges }; } export default function TopologyGraph() { const { edges: storeEdges, selectedNodeId, setSelectedNode, getFilteredNodes, orientation, viewMode, highlightPath } = useTopologyStore(useShallow((s) => ({ edges: s.edges, selectedNodeId: s.selectedNodeId, setSelectedNode: s.setSelectedNode, getFilteredNodes: s.getFilteredNodes, orientation: s.orientation, viewMode: s.viewMode, highlightPath: s.highlightPath, }))); // Memoize the layout computation instead of useState + useEffect const { nodes, edges } = useMemo(() => { const filteredNodes = getFilteredNodes(); if (filteredNodes.length === 0) { return { nodes: [] as Node[], edges: [] as Edge[] }; } const nodeIds = new Set(filteredNodes.map(n => n.id)); const newNodes: Node[] = filteredNodes.map(node => ({ id: node.id, type: 'custom', position: { x: 0, y: 0 }, data: { label: node.name, type: node.type, status: node.data.status, category: node.data.category, ip: node.data.ip, }, selected: node.id === selectedNodeId, })); const newEdges: Edge[] = storeEdges .filter(e => nodeIds.has(e.source) && nodeIds.has(e.target)) .map(edge => { const isPathEdge = highlightPath.includes(edge.source) && highlightPath.includes(edge.target); const isSelected = edge.source === selectedNodeId || edge.target === selectedNodeId; return { id: edge.id, source: edge.source, target: edge.target, type: 'smoothstep', animated: isSelected || isPathEdge, style: { stroke: isSelected ? '#38BDF8' : isPathEdge ? '#818CF8' : '#475569', strokeWidth: isSelected || isPathEdge ? 2 : 1, opacity: highlightPath.length > 0 && !isPathEdge ? 0.3 : 1 }, markerEnd: { type: 'arrowclosed' as const, color: isSelected ? '#38BDF8' : isPathEdge ? '#818CF8' : '#475569', }, }; }); return getLayoutedElements(newNodes, newEdges, orientation); }, [getFilteredNodes, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]); const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { setSelectedNode(node.id); }, [setSelectedNode]); const onPaneClick = useCallback(() => { setSelectedNode(null); }, [setSelectedNode]); return (
getNodeColor(node.data?.type as NodeType || 'service', (node.data?.category as ServiceCategory) || undefined)} maskColor="rgba(15, 23, 42, 0.8)" />
); }