diff --git a/dist/index.html b/dist/index.html index 6eb1dad..48d8c7f 100644 --- a/dist/index.html +++ b/dist/index.html @@ -9,10 +9,10 @@ - - - - + + + +
diff --git a/src/App.tsx b/src/App.tsx index 088e27a..9ac5cc2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -86,10 +86,15 @@ function App() { const pollIntervalRef = useRef(pollInterval); pollIntervalRef.current = pollInterval; - const loadData = useCallback(async () => { + const loadData = useCallback(async (isBackgroundPoll = false) => { if (isLoadingRef.current) return; - setIsLoading(true); + // Only set loading state if there are no existing nodes (initial fresh load) + // or if this is an explicit user refresh (not background poll). + const isInitialLoad = useTopologyStore.getState().nodes.length === 0; + if (isInitialLoad && !isBackgroundPoll) { + setIsLoading(true); + } try { const response = await fetch(`${API_BASE_URL}/api/discover`, { @@ -184,18 +189,20 @@ function App() { }, [toggleCommandPalette]); useEffect(() => { - loadData(); + const isInitialLoad = useTopologyStore.getState().nodes.length === 0; + loadData(!isInitialLoad); // Poll in background if we already have nodes }, []); useEffect(() => { - const intervalId = setInterval(loadData, pollIntervalRef.current); + // Rely on pollInterval from store state instead of ref + const intervalId = setInterval(() => loadData(true), pollInterval); return () => clearInterval(intervalId); - }, [loadData]); + }, [loadData, pollInterval]); // --- WebSocket connection (websocket-engineer skill) --- useEffect(() => { const socket: Socket = ioClient(API_BASE_URL, { - transports: ['websocket', 'polling'], + transports: ['polling', 'websocket'], reconnectionDelay: 1000, reconnectionDelayMax: 5000, reconnectionAttempts: Infinity, @@ -214,7 +221,14 @@ function App() { }); // Listen for real-time topology updates + + let lastWsUpdate = 0; socket.on('topology:update', (data: ApiDiscoveryResponse) => { + // Throttle updates to max 1 per second to prevent UI freezes + const now = Date.now(); + if (now - lastWsUpdate < 1000) return; + lastWsUpdate = now; + if (data?.hosts) { const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({ name: h.name, @@ -288,9 +302,10 @@ const Footer = memo(function Footer() { const pollIntervalRef = useRef(pollInterval); pollIntervalRef.current = pollInterval; - const formatTime = (date: Date | null) => { + const formatTime = (date: Date | string | null) => { if (!date) return 'Never'; - return date.toLocaleTimeString(); + const d = new Date(date); + return isNaN(d.getTime()) ? 'Never' : d.toLocaleTimeString(); }; useEffect(() => { diff --git a/src/components/Dashboard/HostChart.tsx b/src/components/Dashboard/HostChart.tsx index 5881277..e3c7ca6 100644 --- a/src/components/Dashboard/HostChart.tsx +++ b/src/components/Dashboard/HostChart.tsx @@ -28,25 +28,35 @@ export default function HostChart() { ); const hostData = useMemo(() => { - // Find host-type nodes - const hosts = nodes.filter( - (n) => - n.type === 'host_physical' || - n.type === 'host_vm' || - n.type === 'host_container' - ); + // Build a parent mapping of children counts in one O(N) pass + const hostChildrenCounts = new Map(); + const hosts: typeof nodes = []; + + nodes.forEach(n => { + if (n.type === 'host_physical' || n.type === 'host_vm' || n.type === 'host_container') { + hosts.push(n); + if (!hostChildrenCounts.has(n.id)) { + hostChildrenCounts.set(n.id, { total: 0, running: 0, stopped: 0 }); + } + } + + if (n.data.parentId) { + const parentCounts = hostChildrenCounts.get(n.data.parentId) || { total: 0, running: 0, stopped: 0 }; + parentCounts.total++; + if (n.data.status === 'running') parentCounts.running++; + else if (n.data.status === 'stopped') parentCounts.stopped++; + hostChildrenCounts.set(n.data.parentId, parentCounts); + } + }); - // Count children (services/containers) per host return hosts .map((host) => { - const children = nodes.filter((n) => n.data.parentId === host.id); - const running = children.filter((n) => n.data.status === 'running').length; - const stopped = children.filter((n) => n.data.status === 'stopped').length; + const counts = hostChildrenCounts.get(host.id) || { total: 0, running: 0, stopped: 0 }; return { name: host.name, - total: children.length, - running, - stopped, + total: counts.total, + running: counts.running, + stopped: counts.stopped, status: host.data.status, }; }) diff --git a/src/components/Dashboard/MetricsBar.tsx b/src/components/Dashboard/MetricsBar.tsx index 0441994..cc7f2a2 100644 --- a/src/components/Dashboard/MetricsBar.tsx +++ b/src/components/Dashboard/MetricsBar.tsx @@ -105,11 +105,11 @@ export default function MetricsBar() { {/* Last updated */} - {lastUpdated && ( + {lastUpdated && !isNaN(new Date(lastUpdated).getTime()) && (
- {lastUpdated.toLocaleTimeString()} + {new Date(lastUpdated).toLocaleTimeString()}
)} diff --git a/src/components/Graph/TopologyGraph.tsx b/src/components/Graph/TopologyGraph.tsx index 1dcaf94..e419ae8 100644 --- a/src/components/Graph/TopologyGraph.tsx +++ b/src/components/Graph/TopologyGraph.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, memo } from 'react'; +import { useCallback, useMemo, memo, useEffect } from 'react'; import { ReactFlow, Background, @@ -9,13 +9,14 @@ import { NodeProps, Handle, Position, + useReactFlow } 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 { Server, Network, Wifi, Box, Database, Folder, ChevronDown, ChevronRight } from 'lucide-react'; import { NodeType, ServiceCategory } from '../../types'; const nodeIcons: Record = { @@ -35,60 +36,84 @@ const nodeIcons: Record = { }; 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 { highlightPath, collapsedNodes, toggleNodeCollapse } = useTopologyStore(useShallow(s => ({ + highlightPath: s.highlightPath, + collapsedNodes: s.collapsedNodes, + toggleNodeCollapse: s.toggleNodeCollapse + }))); + const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string; hasChildren?: boolean }; 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; + const isCollapsed = collapsedNodes.includes(id); return ( -
+
- + }`} + style={{ + backgroundColor: isDimmed ? '#0F172A' : '#1E293B', + minWidth: '140px', + opacity: isDimmed ? 0.4 : 1 + }} + role="treeitem" + aria-label={`${nodeData.label || 'Node'}, ${nodeData.type?.replace(/_/g, ' ') || 'unknown type'}, ${nodeData.status || 'unknown status'}`} + > + -
-
- -
- -
-
- {nodeData.label} -
- {nodeData.ip && ( -
- {nodeData.ip} +
+
+ - )} +
+ +
+
+ {nodeData.label} +
+ {nodeData.ip && ( +
+ {nodeData.ip} +
+ )} +
+ +
-
+
- + {nodeData.hasChildren && ( + + )}
); }); @@ -132,28 +157,50 @@ function getLayoutedElements(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB return { nodes: layoutedNodes, edges }; } +import { useFilteredNodes } from '../../store/topologyStore'; + export default function TopologyGraph() { const { edges: storeEdges, selectedNodeId, setSelectedNode, - getFilteredNodes, orientation, viewMode, - highlightPath + highlightPath, + collapsedNodes } = useTopologyStore(useShallow((s) => ({ edges: s.edges, selectedNodeId: s.selectedNodeId, setSelectedNode: s.setSelectedNode, - getFilteredNodes: s.getFilteredNodes, orientation: s.orientation, viewMode: s.viewMode, highlightPath: s.highlightPath, + collapsedNodes: s.collapsedNodes }))); + const filteredNodesList = useFilteredNodes(); + const { fitView } = useReactFlow(); + + // Watch for collapse/expand events to recenter the graph nicely + const collapsedNodesHash = collapsedNodes.join(','); + useEffect(() => { + // Wait for dagre to compute the new layout locations before centering + const timeoutId = setTimeout(() => { + // Find the first node available if none is selected + const currentNodes = useTopologyStore.getState().nodes; + const targetNodeId = selectedNodeId || currentNodes[0]?.id; + + if (targetNodeId) { + fitView({ nodes: [{ id: targetNodeId }], duration: 800, maxZoom: 1 }); + } + }, 100); + + return () => clearTimeout(timeoutId); + }, [collapsedNodesHash, fitView, selectedNodeId]); + // Memoize the layout computation instead of useState + useEffect const { nodes, edges } = useMemo(() => { - const filteredNodes = getFilteredNodes(); + const filteredNodes = filteredNodesList; if (filteredNodes.length === 0) { return { nodes: [] as Node[], edges: [] as Edge[] }; @@ -171,6 +218,7 @@ export default function TopologyGraph() { status: node.data.status, category: node.data.category, ip: node.data.ip, + hasChildren: collapsedNodes.includes(node.id) || storeEdges.some(e => e.source === node.id && nodeIds.has(e.target)) }, selected: node.id === selectedNodeId, })); @@ -209,7 +257,7 @@ export default function TopologyGraph() { }); return getLayoutedElements(newNodes, newEdges, orientation); - }, [getFilteredNodes, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]); + }, [filteredNodesList, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]); const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { setSelectedNode(node.id); @@ -229,7 +277,7 @@ export default function TopologyGraph() { nodeTypes={nodeTypes} fitView fitViewOptions={{ padding: 0.2 }} - minZoom={0.1} + minZoom={0.4} maxZoom={2} defaultEdgeOptions={{ type: 'smoothstep', diff --git a/src/components/LeftPanel.tsx b/src/components/LeftPanel.tsx index 54ac1d4..12c06e9 100644 --- a/src/components/LeftPanel.tsx +++ b/src/components/LeftPanel.tsx @@ -48,15 +48,17 @@ export default function LeftPanel() { ); const selectedNode = nodes.find(n => n.id === selectedNodeId); - const childNodes = nodes.filter(n => n.data.parentId === selectedNodeId); - const groupedChildren = useMemo(() => { - return childNodes.reduce((acc, node) => { + const { childNodes, groupedChildren } = useMemo(() => { + const children = nodes.filter(n => n.data.parentId === selectedNodeId); + const grouped = children.reduce((acc, node) => { if (!acc[node.type]) acc[node.type] = []; acc[node.type].push(node); return acc; }, {} as Record); - }, [childNodes]); + + return { childNodes: children, groupedChildren: grouped }; + }, [nodes, selectedNodeId]); return (