From fa6777a04248ece6cf78e3f27d672d9cc0148b80 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Thu, 23 Oct 2025 20:36:07 +0200 Subject: [PATCH] feat: live topology view through websocket updates --- .../FIRMWARE_REGISTRY_INTEGRATION.md | 0 docs/TOPOLOGY_WEBSOCKET_UPDATE.md | 248 ++++++ .../components/TopologyGraphComponent.js | 749 +++++++++++++----- public/scripts/view-models.js | 162 ++-- 4 files changed, 909 insertions(+), 250 deletions(-) rename FIRMWARE_REGISTRY_INTEGRATION.md => docs/FIRMWARE_REGISTRY_INTEGRATION.md (100%) create mode 100644 docs/TOPOLOGY_WEBSOCKET_UPDATE.md diff --git a/FIRMWARE_REGISTRY_INTEGRATION.md b/docs/FIRMWARE_REGISTRY_INTEGRATION.md similarity index 100% rename from FIRMWARE_REGISTRY_INTEGRATION.md rename to docs/FIRMWARE_REGISTRY_INTEGRATION.md diff --git a/docs/TOPOLOGY_WEBSOCKET_UPDATE.md b/docs/TOPOLOGY_WEBSOCKET_UPDATE.md new file mode 100644 index 0000000..ac02e36 --- /dev/null +++ b/docs/TOPOLOGY_WEBSOCKET_UPDATE.md @@ -0,0 +1,248 @@ +# Topology Component WebSocket Integration + +## Summary +Enhanced the topology graph component to support real-time node additions and removals via WebSocket connections. The topology view now automatically updates when nodes join or leave the cluster without requiring manual refresh. Existing nodes update their properties (status, labels) smoothly in place without being removed and re-added. + +## Changes Made + +### 1. TopologyViewModel (`spore-ui/public/scripts/view-models.js`) + +Added `setupWebSocketListeners()` method to the TopologyViewModel class: + +- **Listens to `clusterUpdate` events**: When cluster membership changes, the topology graph is automatically rebuilt with the new node data +- **Listens to `nodeDiscovery` events**: When a new node is discovered or becomes stale, triggers a topology update +- **Listens to connection status**: Automatically refreshes topology when WebSocket reconnects +- **Async graph updates**: Rebuilds graph data asynchronously from WebSocket data to avoid blocking the UI + +Enhanced `buildEnhancedGraphData()` method to preserve node state: + +- **Position preservation**: Existing nodes retain their x, y coordinates across updates +- **Velocity preservation**: D3 simulation velocity (vx, vy) is maintained for smooth physics +- **Fixed position preservation**: Manually dragged nodes (fx, fy) stay in place +- **New nodes only**: Only newly discovered nodes get random initial positions +- **Result**: Nodes no longer "jump" or get removed/re-added when their properties update + +### 2. TopologyGraphComponent (`spore-ui/public/scripts/components/TopologyGraphComponent.js`) + +#### Added WebSocket Setup +- Added `setupWebSocketListeners()` method that calls the view model's WebSocket setup during component initialization +- Integrated into the `initialize()` lifecycle method + +#### Improved Dynamic Updates (D3.js Enter/Exit Pattern) +Refactored the graph rendering to use D3's data binding patterns for smooth transitions: + +- **`updateLinks()`**: Uses enter/exit pattern to add/remove links with fade transitions +- **`updateNodes()`**: Uses enter/exit pattern to add/remove nodes with fade transitions + - New nodes fade in (300ms transition) + - Removed nodes fade out (300ms transition) + - Existing nodes smoothly update their properties +- **`updateLinkLabels()`**: Dynamically updates link latency labels +- **`updateSimulation()`**: Handles D3 force simulation updates + - Creates new simulation on first render + - Updates existing simulation with new node/link data on subsequent renders + - Maintains smooth physics-based layout +- **`addLegend()`**: Fixed to prevent duplicate legend creation + +#### Key Improvements +- **Incremental updates**: Instead of recreating the entire graph, only modified nodes/links are added or removed +- **Smooth animations**: 300ms fade transitions for adding/removing elements +- **In-place updates**: Existing nodes update their properties without being removed/re-added +- **Preserved interactions**: Click, hover, and drag interactions work seamlessly with dynamic updates +- **Efficient rendering**: D3's data binding with key functions ensures optimal DOM updates +- **Intelligent simulation**: Uses different alpha values (0.1 for updates, 0.3 for additions/removals) to minimize disruption +- **Drag-aware updates**: WebSocket updates are deferred while dragging and applied after drag completes +- **Uninterrupted dragging**: Drag operations are never interrupted by incoming updates +- **Rearrange button**: Convenient UI control to reset node layout and clear manual positioning + +## How It Works + +### Data Flow +``` +WebSocket Server (spore-ui backend) + ↓ (cluster_update / node_discovery events) +WebSocketClient (api-client.js) + ↓ (emits clusterUpdate / nodeDiscovery events) +TopologyViewModel.setupWebSocketListeners() + ↓ (builds graph data, updates state) +TopologyGraphComponent subscriptions + ↓ (renderGraph() called automatically) + ├─ If dragging: queue update in pendingUpdate + └─ If not dragging: apply update immediately +D3.js enter/exit pattern + ↓ (smooth visual updates) +Updated Topology Graph +``` + +### Simplified Update Architecture + +**Core Principle**: The D3 simulation is the single source of truth for positions. + +#### How It Works: + +1. **Drag Deferral**: + - `isDragging` flag blocks updates during drag + - Updates queued in `pendingUpdate` and applied after drag ends + - Dragged positions saved in `draggedNodePositions` Map for persistence + +2. **Position Merging** (in `updateNodes()`): + - When simulation exists: copy live positions from simulation nodes to new data + - This preserves ongoing animations and velocities + - Then apply dragged positions (if any) as overrides + - Result: Always use most current position state + +3. **Smart Simulation Updates** (in `updateSimulation()`): + - **Structural changes** (nodes added/removed): restart with alpha=0.3 + - **Property changes** (status, labels): DON'T restart - just update data + - Simulation continues naturally for property-only changes + - No unnecessary disruptions to ongoing animations + +This ensures: +- ✅ Simulation is authoritative for positions +- ✅ No position jumping during animations +- ✅ Property updates don't disrupt node movement +- ✅ Dragged positions always respected +- ✅ Simple, clean logic with one source of truth + +### WebSocket Events Handled + +1. **`clusterUpdate`** (from `cluster_update` message type) + - Payload: `{ members: [...], primaryNode: string, totalNodes: number, timestamp: string }` + - Action: Rebuilds graph with current cluster state + +2. **`nodeDiscovery`** (from `node_discovery` message type) + - Payload: `{ action: 'discovered' | 'stale', nodeIp: string, timestamp: string }` + - Action: Triggers topology refresh after 500ms delay + +3. **`connected`** (WebSocket connection established) + - Action: Triggers topology refresh after 1000ms delay + +4. **`disconnected`** (WebSocket connection lost) + - Action: Logs disconnection (no action taken) + +## Benefits + +1. **Real-time Updates**: Topology reflects cluster state changes immediately +2. **Smooth Transitions**: Nodes and links fade in/out gracefully +3. **Better UX**: No manual refresh needed +4. **Efficient**: Only updates changed elements, not entire graph +5. **Resilient**: Automatically refreshes on reconnection +6. **Consistent**: Uses same WebSocket infrastructure as ClusterStatusComponent + +## Testing + +To test the WebSocket integration: + +1. **Start the application**: + ```bash + cd spore-ui + node index-standalone.js + ``` + +2. **Open the UI** and navigate to the Topology view + +3. **Add a node**: Start a new SPORE device on the network + - Watch it appear in the topology graph within seconds + - Node should fade in smoothly + +4. **Remove a node**: Stop a SPORE device + - Watch it fade out from the topology graph + - Connected links should also disappear + +5. **Status changes**: Change node status (active → inactive → dead) + - Node colors should update automatically + - Status indicators should change + +6. **Drag during updates**: + - Start dragging a node + - While dragging, trigger a cluster update (add/remove/change another node) + - Drag should continue smoothly without interruption + - After releasing, the update should be applied immediately + - **Important**: The dragged node should stay at its final position, not revert + +7. **Position persistence after drag**: + - Drag a node to a new position and release + - Trigger multiple WebSocket updates (status changes, new nodes, etc.) + - The dragged node should remain in its new position through all updates + - Only when the node is removed should its position be forgotten + +8. **Update during animation**: + - Let the graph settle (simulation running, nodes animating to stable positions) + - While nodes are still moving, trigger a WebSocket update (status change) + - **Expected**: Nodes should continue their smooth animation without jumping + - **No flickering**: Positions should not snap back and forth + - Animation should feel continuous and natural + +9. **Single node scenario**: + - Start with multiple nodes in the topology + - Remove nodes one by one until only one remains + - **Expected**: Single node stays visible, no "loading" message + - Graph should render correctly with just one node + - Remove the last node + - **Expected**: "No cluster members found" message appears + +10. **Rearrange nodes**: + - Drag nodes to custom positions manually + - Click the "Rearrange" button in the top-left corner + - **Expected**: All nodes reset to physics-based positions + - Dragged positions cleared, simulation restarts + - Nodes animate to a clean, evenly distributed layout + +11. **WebSocket reconnection**: + - Disconnect from network briefly + - Reconnect + - Topology should refresh automatically + +## Technical Notes + +### Architecture +- **Single Source of Truth**: D3 simulation manages all position state +- **Key Functions**: D3 data binding uses node IPs as keys to track identity +- **Transition Duration**: 300ms for fade in/out animations + +### Position Management (Simplified!) +- **updateNodes()**: Copies live positions from simulation to new data before binding +- **No complex syncing**: Simulation state flows naturally to new data +- **Dragged positions**: Override via `draggedNodePositions` Map (always respected) + +### Simulation Behavior +- **Structural changes** (add/remove nodes): Restart with alpha=0.3 +- **Property changes** (status, labels): No restart - data updated in-place +- **Drag operations**: Simulation updates blocked entirely +- **Result**: Smooth animations for property updates, controlled restart for structure changes + +### Drag Management +- **isDragging flag**: Blocks all updates during drag +- **pendingUpdate**: Queues one update, applied 50ms after drag ends +- **draggedNodePositions Map**: Persists manual positions across all updates +- **Cleanup**: Map entries removed when nodes deleted + +### Performance +- **No unnecessary restarts**: Property-only updates don't disrupt simulation +- **Efficient merging**: Position data copied via Map lookup (O(n)) +- **Memory efficient**: Only active nodes tracked, old entries cleaned up +- **Smooth animations**: Velocity and momentum preserved across updates + +### Edge Cases Handled +- **Single node**: Graph renders correctly with just one node +- **Transient states**: Loading/no-data states don't clear existing SVG +- **Update races**: SVG preserved even if loading state triggered during render +- **Empty to non-empty**: Smooth transition from loading to first node + +## Future Enhancements + +Possible improvements for future iterations: + +1. **Diff-based updates**: Only rebuild graph when node/link structure actually changes +2. **Visual indicators**: Show "new node" or "leaving node" badges temporarily +3. **Connection health**: Real-time latency updates on links without full rebuild +4. **Throttling**: Debounce rapid successive updates +5. **Persistent layout**: Save and restore user-arranged topology layouts +6. **Zoom to node**: Auto-zoom to newly added nodes with animation + +## Related Files + +- `spore-ui/public/scripts/view-models.js` - TopologyViewModel class +- `spore-ui/public/scripts/components/TopologyGraphComponent.js` - Topology visualization component +- `spore-ui/public/scripts/api-client.js` - WebSocketClient class +- `spore-ui/index-standalone.js` - WebSocket server implementation + diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js index ea366c0..1bf32da 100644 --- a/public/scripts/components/TopologyGraphComponent.js +++ b/public/scripts/components/TopologyGraphComponent.js @@ -15,6 +15,11 @@ class TopologyGraphComponent extends Component { // Tooltip for labels on hover this.tooltipEl = null; + + // Track drag state to defer updates + this.isDragging = false; + this.pendingUpdate = null; + this.draggedNodePositions = new Map(); // Track final positions of dragged nodes } // Determine desktop threshold @@ -253,10 +258,26 @@ class TopologyGraphComponent extends Component { this._pendingSubscriptions = null; } + // Set up WebSocket listeners for real-time updates + this.setupWebSocketListeners(); + // Initial data load await this.viewModel.updateNetworkTopology(); } + setupWebSocketListeners() { + logger.debug('TopologyGraphComponent: Setting up WebSocket listeners...'); + + // The view model handles WebSocket events and updates its state + // The component will automatically re-render via view model subscriptions + if (this.viewModel && typeof this.viewModel.setupWebSocketListeners === 'function') { + this.viewModel.setupWebSocketListeners(); + logger.debug('TopologyGraphComponent: WebSocket listeners configured'); + } else { + logger.warn('TopologyGraphComponent: View model does not support WebSocket listeners'); + } + } + setupSVG() { const container = this.findElement('#topology-graph-container'); if (!container) { @@ -270,14 +291,23 @@ class TopologyGraphComponent extends Component { // Clear existing content container.innerHTML = ''; + // Create a wrapper for SVG and controls + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + wrapper.style.width = '100%'; + wrapper.style.height = '100%'; + container.appendChild(wrapper); + + // Add rearrange button + this.createRearrangeButton(wrapper); + // Create SVG element - this.svg = d3.select(container) + this.svg = d3.select(wrapper) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('viewBox', `0 0 ${this.width} ${this.height}`) .style('border', '1px solid rgba(255, 255, 255, 0.1)') - //.style('background', 'rgba(0, 0, 0, 0.2)') .style('border-radius', '12px'); // Add zoom behavior @@ -298,6 +328,94 @@ class TopologyGraphComponent extends Component { logger.debug('TopologyGraphComponent: SVG setup completed'); } + createRearrangeButton(container) { + const button = document.createElement('button'); + button.className = 'topology-rearrange-btn'; + button.innerHTML = ` + + + + + + + + + Rearrange + `; + button.title = 'Rearrange nodes into a clean layout'; + button.style.cssText = ` + position: absolute; + left: 16px; + top: 16px; + z-index: 10; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: var(--card-background, rgba(30, 30, 30, 0.95)); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); + border-radius: 8px; + color: var(--text-primary, #ecf0f1); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + `; + + // Add hover effect + button.addEventListener('mouseenter', () => { + button.style.background = 'var(--card-hover, rgba(40, 40, 40, 0.95))'; + button.style.borderColor = 'var(--primary-color, #3498db)'; + button.style.transform = 'translateY(-1px)'; + button.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; + }); + + button.addEventListener('mouseleave', () => { + button.style.background = 'var(--card-background, rgba(30, 30, 30, 0.95))'; + button.style.borderColor = 'var(--border-color, rgba(255, 255, 255, 0.1))'; + button.style.transform = 'translateY(0)'; + button.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)'; + }); + + button.addEventListener('click', () => { + this.rearrangeNodes(); + }); + + container.appendChild(button); + } + + rearrangeNodes() { + logger.debug('TopologyGraphComponent: Rearranging nodes'); + + // Clear all manually dragged positions + this.draggedNodePositions.clear(); + logger.debug('TopologyGraphComponent: Cleared dragged positions'); + + if (!this.simulation) { + logger.warn('TopologyGraphComponent: No simulation to rearrange'); + return; + } + + // Get current nodes and reset their fixed positions + const nodes = this.simulation.nodes(); + nodes.forEach(node => { + node.fx = null; + node.fy = null; + // Give them a slight random velocity to help spread out + node.vx = (Math.random() - 0.5) * 50; + node.vy = (Math.random() - 0.5) * 50; + }); + + // Restart the simulation with high alpha for a fresh layout + this.simulation + .alpha(1) + .alphaTarget(0) + .restart(); + + logger.debug('TopologyGraphComponent: Simulation restarted for rearrangement'); + } + // Ensure component is initialized async ensureInitialized() { if (!this.isInitialized) { @@ -324,6 +442,16 @@ class TopologyGraphComponent extends Component { const nodes = this.viewModel.get('nodes'); const links = this.viewModel.get('links'); + // Defer updates while dragging + if (this.isDragging) { + logger.debug('TopologyGraphComponent: Drag in progress, deferring update'); + this.pendingUpdate = { nodes, links }; + return; + } + + // Clear any pending update since we're processing now + this.pendingUpdate = null; + // Check if SVG is initialized if (!this.svg) { logger.debug('TopologyGraphComponent: SVG not initialized yet, setting up SVG first'); @@ -346,162 +474,13 @@ class TopologyGraphComponent extends Component { svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); } - // Clear existing graph elements but preserve the main group and its transform - svgGroup.selectAll('.graph-element').remove(); - - // Create links - const link = svgGroup.append('g') - .attr('class', 'graph-element') - .selectAll('line') - .data(links) - .enter().append('line') - .attr('stroke', d => this.getLinkColor(d.latency)) - .attr('stroke-opacity', 0.7) - .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) - .attr('marker-end', null); - - // Create nodes - const node = svgGroup.append('g') - .attr('class', 'graph-element') - .selectAll('g') - .data(nodes) - .enter().append('g') - .attr('class', 'node') - .call(this.drag(this.simulation)); - - // Add circles to nodes - node.append('circle') - .attr('r', d => this.getNodeRadius(d.status)) - .attr('fill', d => this.getNodeColor(d.status)) - .attr('stroke', '#fff') - .attr('stroke-width', 2); - - // Status indicator - node.append('circle') - .attr('r', 3) - .attr('fill', d => this.getStatusIndicatorColor(d.status)) - .attr('cx', -8) - .attr('cy', -8); - - // Hostname - node.append('text') - .text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname) - .attr('x', 15) - .attr('y', 4) - .attr('font-size', '13px') - .attr('fill', 'var(--text-primary)') - .attr('font-weight', '500'); - - // IP - node.append('text') - .text(d => d.ip) - .attr('x', 15) - .attr('y', 22) - .attr('font-size', '11px') - .attr('fill', 'var(--text-secondary)'); - - // App label (between IP and Status) - node.append('text') - .text(d => (d.labels && d.labels.app) ? String(d.labels.app) : '') - .attr('x', 15) - .attr('y', 38) - .attr('font-size', '11px') - .attr('fill', 'var(--text-secondary)') - .attr('font-weight', '500') - .attr('display', d => (d.labels && d.labels.app) ? null : 'none'); - - // Status text - node.append('text') - .text(d => d.status) - .attr('x', 15) - .attr('y', 56) - .attr('font-size', '11px') - .attr('fill', d => this.getNodeColor(d.status)) - .attr('font-weight', '600'); - - // Latency labels on links - const linkLabels = svgGroup.append('g') - .attr('class', 'graph-element') - .selectAll('text') - .data(links) - .enter().append('text') - .attr('font-size', '12px') - .attr('fill', 'var(--text-primary)') - .attr('font-weight', '600') - .attr('text-anchor', 'middle') - .style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') - .text(d => `${d.latency}ms`); - - // Simulation - if (!this.simulation) { - this.simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.id).distance(300)) - .force('charge', d3.forceManyBody().strength(-800)) - .force('center', d3.forceCenter(this.width / 2, this.height / 2)) - .force('collision', d3.forceCollide().radius(80)); - - this.simulation.on('tick', () => { - link - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y); - - linkLabels - .attr('x', d => (d.source.x + d.target.x) / 2) - .attr('y', d => (d.source.y + d.target.y) / 2 - 5); - - node - .attr('transform', d => `translate(${d.x},${d.y})`); - }); - } else { - this.simulation.nodes(nodes); - this.simulation.force('link').links(links); - this.simulation.alpha(0.3).restart(); - } - - // Node interactions - node.on('click', (event, d) => { - this.viewModel.selectNode(d.id); - this.updateSelection(d.id); - if (this.isDesktop()) { - // Desktop: open slide-in drawer, reuse NodeDetailsComponent - this.openDrawerForNode(d); - } else { - // Mobile/low-res: keep existing overlay - this.showMemberCardOverlay(d); - } - }); - - node.on('mouseover', (event, d) => { - d3.select(event.currentTarget).select('circle') - .attr('r', d => this.getNodeRadius(d.status) + 4) - .attr('stroke-width', 3); - this.showTooltip(d, event.pageX, event.pageY); - }); - - node.on('mouseout', (event, d) => { - d3.select(event.currentTarget).select('circle') - .attr('r', d => this.getNodeRadius(d.status)) - .attr('stroke-width', 2); - this.hideTooltip(); - }); - - node.on('mousemove', (event, d) => { - this.moveTooltip(event.pageX, event.pageY); - }); - - link.on('mouseover', (event, d) => { - d3.select(event.currentTarget) - .attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6))) - .attr('stroke-opacity', 0.9); - }); - - link.on('mouseout', (event, d) => { - d3.select(event.currentTarget) - .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) - .attr('stroke-opacity', 0.7); - }); + // Use D3's enter/exit pattern for smooth dynamic updates + this.updateLinks(svgGroup, links); + this.updateNodes(svgGroup, nodes); + this.updateLinkLabels(svgGroup, links); + + // Update or create simulation + this.updateSimulation(nodes, links, svgGroup); this.addLegend(svgGroup); } catch (error) { @@ -509,9 +488,355 @@ class TopologyGraphComponent extends Component { } } + updateLinks(svgGroup, links) { + // Get or create link group + let linkGroup = svgGroup.select('.link-group'); + if (linkGroup.empty()) { + linkGroup = svgGroup.append('g').attr('class', 'link-group graph-element'); + } + + // Bind data with key function for proper enter/exit + // D3's forceLink mutates source/target from strings to objects, so we need to normalize the key + const link = linkGroup.selectAll('line') + .data(links, d => { + const sourceId = typeof d.source === 'object' ? d.source.id : d.source; + const targetId = typeof d.target === 'object' ? d.target.id : d.target; + // Always return keys in consistent order (smaller IP first) for bidirectional links + return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`; + }); + + // Remove old links + link.exit() + .transition() + .duration(300) + .style('opacity', 0) + .remove(); + + // Add new links + const linkEnter = link.enter().append('line') + .attr('stroke-opacity', 0) + .attr('marker-end', null); + + // Merge and update all links + const linkMerge = linkEnter.merge(link) + .transition() + .duration(300) + .attr('stroke', d => this.getLinkColor(d.latency)) + .attr('stroke-opacity', 0.7) + .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))); + + // Store reference for simulation + this.linkSelection = linkGroup.selectAll('line'); + + // Add interactions to links + this.linkSelection + .on('mouseover', (event, d) => { + d3.select(event.currentTarget) + .attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6))) + .attr('stroke-opacity', 0.9); + }) + .on('mouseout', (event, d) => { + d3.select(event.currentTarget) + .attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) + .attr('stroke-opacity', 0.7); + }); + } + + updateNodes(svgGroup, nodes) { + // Get or create node group + let nodeGroup = svgGroup.select('.node-group'); + if (nodeGroup.empty()) { + nodeGroup = svgGroup.append('g').attr('class', 'node-group graph-element'); + } + + // Merge live simulation positions with new data + if (this.simulation) { + const simulationNodes = this.simulation.nodes(); + const simNodeMap = new Map(simulationNodes.map(n => [n.id, n])); + + nodes.forEach(node => { + const simNode = simNodeMap.get(node.id); + if (simNode) { + // Keep simulation's position data (source of truth) + node.x = simNode.x; + node.y = simNode.y; + node.vx = simNode.vx; + node.vy = simNode.vy; + node.fx = simNode.fx; + node.fy = simNode.fy; + } + + // Apply saved dragged positions (always pin these) + const draggedPos = this.draggedNodePositions.get(node.id); + if (draggedPos) { + node.x = draggedPos.x; + node.y = draggedPos.y; + } + }); + } + + // Bind data with key function + const node = nodeGroup.selectAll('g.node') + .data(nodes, d => d.id); + + // Remove old nodes and clean up their dragged positions + node.exit() + .each((d) => { + // Clean up dragged position data for removed nodes + this.draggedNodePositions.delete(d.id); + logger.debug(`TopologyGraphComponent: Cleaned up dragged position for removed node ${d.id}`); + }) + .transition() + .duration(300) + .style('opacity', 0) + .remove(); + + // Add new nodes + const nodeEnter = node.enter().append('g') + .attr('class', 'node') + .style('opacity', 0) + .call(this.drag(this.simulation)); + + // Add circles to new nodes + nodeEnter.append('circle') + .attr('r', d => this.getNodeRadius(d.status)) + .attr('fill', d => this.getNodeColor(d.status)) + .attr('stroke', '#fff') + .attr('stroke-width', 2); + + // Status indicator + nodeEnter.append('circle') + .attr('r', 3) + .attr('fill', d => this.getStatusIndicatorColor(d.status)) + .attr('cx', -8) + .attr('cy', -8); + + // Hostname + nodeEnter.append('text') + .attr('class', 'hostname-text') + .text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname) + .attr('x', 15) + .attr('y', 4) + .attr('font-size', '13px') + .attr('fill', 'var(--text-primary)') + .attr('font-weight', '500'); + + // IP + nodeEnter.append('text') + .attr('class', 'ip-text') + .text(d => d.ip) + .attr('x', 15) + .attr('y', 22) + .attr('font-size', '11px') + .attr('fill', 'var(--text-secondary)'); + + // App label + nodeEnter.append('text') + .attr('class', 'app-text') + .text(d => (d.labels && d.labels.app) ? String(d.labels.app) : '') + .attr('x', 15) + .attr('y', 38) + .attr('font-size', '11px') + .attr('fill', 'var(--text-secondary)') + .attr('font-weight', '500') + .attr('display', d => (d.labels && d.labels.app) ? null : 'none'); + + // Status text + nodeEnter.append('text') + .attr('class', 'status-text') + .text(d => d.status) + .attr('x', 15) + .attr('y', 56) + .attr('font-size', '11px') + .attr('fill', d => this.getNodeColor(d.status)) + .attr('font-weight', '600'); + + // Merge and update all nodes + const nodeMerge = nodeEnter.merge(node); + + // Update existing node properties with transition + nodeMerge.select('circle:first-child') + .transition() + .duration(300) + .attr('r', d => this.getNodeRadius(d.status)) + .attr('fill', d => this.getNodeColor(d.status)); + + nodeMerge.select('circle:nth-child(2)') + .transition() + .duration(300) + .attr('fill', d => this.getStatusIndicatorColor(d.status)); + + nodeMerge.select('.hostname-text') + .text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname); + + nodeMerge.select('.ip-text') + .text(d => d.ip); + + nodeMerge.select('.app-text') + .text(d => (d.labels && d.labels.app) ? String(d.labels.app) : '') + .attr('display', d => (d.labels && d.labels.app) ? null : 'none'); + + nodeMerge.select('.status-text') + .text(d => d.status) + .transition() + .duration(300) + .attr('fill', d => this.getNodeColor(d.status)); + + // Fade in only new nodes (not existing ones) + nodeEnter.transition() + .duration(300) + .style('opacity', 1); + + // Ensure existing nodes remain visible + node.style('opacity', 1); + + // Store reference for simulation + this.nodeSelection = nodeMerge; + + // Add interactions + this.nodeSelection + .on('click', (event, d) => { + this.viewModel.selectNode(d.id); + this.updateSelection(d.id); + if (this.isDesktop()) { + this.openDrawerForNode(d); + } else { + this.showMemberCardOverlay(d); + } + }) + .on('mouseover', (event, d) => { + d3.select(event.currentTarget).select('circle') + .attr('r', d => this.getNodeRadius(d.status) + 4) + .attr('stroke-width', 3); + this.showTooltip(d, event.pageX, event.pageY); + }) + .on('mouseout', (event, d) => { + d3.select(event.currentTarget).select('circle') + .attr('r', d => this.getNodeRadius(d.status)) + .attr('stroke-width', 2); + this.hideTooltip(); + }) + .on('mousemove', (event, d) => { + this.moveTooltip(event.pageX, event.pageY); + }); + } + + updateLinkLabels(svgGroup, links) { + // Get or create link label group + let linkLabelGroup = svgGroup.select('.link-label-group'); + if (linkLabelGroup.empty()) { + linkLabelGroup = svgGroup.append('g').attr('class', 'link-label-group graph-element'); + } + + // Bind data with same key function as updateLinks for consistency + const linkLabels = linkLabelGroup.selectAll('text') + .data(links, d => { + const sourceId = typeof d.source === 'object' ? d.source.id : d.source; + const targetId = typeof d.target === 'object' ? d.target.id : d.target; + return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`; + }); + + // Remove old labels + linkLabels.exit() + .transition() + .duration(300) + .style('opacity', 0) + .remove(); + + // Add new labels + const linkLabelsEnter = linkLabels.enter().append('text') + .attr('font-size', '12px') + .attr('fill', 'var(--text-primary)') + .attr('font-weight', '600') + .attr('text-anchor', 'middle') + .style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') + .style('opacity', 0); + + // Merge and update + linkLabelsEnter.merge(linkLabels) + .text(d => `${d.latency}ms`) + .transition() + .duration(300) + .style('opacity', 1); + + // Store reference for simulation + this.linkLabelSelection = linkLabelGroup.selectAll('text'); + } + + updateSimulation(nodes, links, svgGroup) { + if (!this.simulation) { + // Create new simulation + this.simulation = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links).id(d => d.id).distance(300)) + .force('charge', d3.forceManyBody().strength(-800)) + .force('center', d3.forceCenter(this.width / 2, this.height / 2)) + .force('collision', d3.forceCollide().radius(80)); + + // Set up tick handler + this.simulation.on('tick', () => { + // Update link positions + if (this.linkSelection) { + this.linkSelection + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + } + + // Update link label positions + if (this.linkLabelSelection) { + this.linkLabelSelection + .attr('x', d => (d.source.x + d.target.x) / 2) + .attr('y', d => (d.source.y + d.target.y) / 2 - 5); + } + + // Update node positions + if (this.nodeSelection) { + this.nodeSelection + .attr('transform', d => `translate(${d.x},${d.y})`); + } + }); + } else { + // Don't update simulation if user is dragging + if (this.isDragging) { + logger.debug('TopologyGraphComponent: Skipping simulation update during drag'); + return; + } + + // Check if this is a structural change (nodes/links added/removed) + const currentNodes = this.simulation.nodes(); + const currentNodeIds = new Set(currentNodes.map(n => n.id)); + const newNodeIds = new Set(nodes.map(n => n.id)); + + const isStructuralChange = currentNodes.length !== nodes.length || + [...newNodeIds].some(id => !currentNodeIds.has(id)) || + [...currentNodeIds].some(id => !newNodeIds.has(id)); + + // Update simulation data + this.simulation.nodes(nodes); + this.simulation.force('link').links(links); + + if (isStructuralChange) { + // Structural change: restart with moderate alpha + logger.debug('TopologyGraphComponent: Structural change detected, restarting simulation'); + this.simulation.alpha(0.3).restart(); + } else { + // Property-only change: just update data, no restart needed + // The simulation keeps running at its current alpha + logger.debug('TopologyGraphComponent: Property-only update, continuing simulation'); + // Don't call restart() - let it continue naturally + } + } + } + addLegend(svgGroup) { + // Only add legend if it doesn't exist + if (!svgGroup.select('.legend-group').empty()) { + return; + } + const legend = svgGroup.append('g') - .attr('class', 'graph-element') + .attr('class', 'legend-group graph-element') .attr('transform', `translate(120, 120)`) // Hidden by CSS opacity .style('opacity', '0'); @@ -617,8 +942,8 @@ class TopologyGraphComponent extends Component { } getLinkColor(latency) { - if (latency <= 30) return '#10b981'; - if (latency <= 50) return '#f59e0b'; + if (latency <= 50) return '#10b981'; + if (latency <= 100) return '#f59e0b'; return '#ef4444'; } @@ -637,26 +962,68 @@ class TopologyGraphComponent extends Component { drag(simulation) { return d3.drag() - .on('start', function(event, d) { + .on('start', (event, d) => { + // Set dragging flag to defer updates + this.isDragging = true; + logger.debug('TopologyGraphComponent: Drag started, updates deferred'); + if (!event.active && simulation && simulation.alphaTarget) { simulation.alphaTarget(0.3).restart(); } d.fx = d.x; d.fy = d.y; }) - .on('drag', function(event, d) { + .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) - .on('end', function(event, d) { + .on('end', (event, d) => { if (!event.active && simulation && simulation.alphaTarget) { simulation.alphaTarget(0); } + + // Save the final position before releasing + const finalX = d.fx; + const finalY = d.fy; + + // Store the dragged position to preserve it across updates + this.draggedNodePositions.set(d.id, { x: finalX, y: finalY }); + logger.debug(`TopologyGraphComponent: Saved dragged position for ${d.id}: (${finalX}, ${finalY})`); + + // Update the node data in view model with the new position + this.updateNodePositionInViewModel(d.id, finalX, finalY); + d.fx = null; d.fy = null; + + // Clear dragging flag + this.isDragging = false; + logger.debug('TopologyGraphComponent: Drag ended'); + + // Process any pending updates + if (this.pendingUpdate) { + logger.debug('TopologyGraphComponent: Processing deferred update after drag'); + // Use setTimeout to ensure drag event completes first + setTimeout(() => { + this.renderGraph(); + }, 50); + } }); } + updateNodePositionInViewModel(nodeId, x, y) { + // Update the node position in the view model to persist the drag + const nodes = this.viewModel.get('nodes'); + if (nodes) { + const node = nodes.find(n => n.id === nodeId); + if (node) { + node.x = x; + node.y = y; + logger.debug(`TopologyGraphComponent: Updated node ${nodeId} position in view model`); + } + } + } + updateSelection(selectedNodeId) { // Update visual selection if (!this.svg || !this.isInitialized) { @@ -668,22 +1035,13 @@ class TopologyGraphComponent extends Component { .attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff'); } + // NOTE: This method is deprecated and should not be used + // The topology graph is now entirely websocket-driven + // Refresh button was removed and all updates come from websocket events handleRefresh() { - logger.debug('TopologyGraphComponent: handleRefresh called'); - - if (!this.isInitialized) { - logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); - this.ensureInitialized().then(() => { - // Refresh after initialization - this.viewModel.updateNetworkTopology(); - }).catch(error => { - logger.error('TopologyGraphComponent: Failed to initialize for refresh:', error); - }); - return; - } - - logger.debug('TopologyGraphComponent: Calling updateNetworkTopology...'); - this.viewModel.updateNetworkTopology(); + logger.warn('TopologyGraphComponent: handleRefresh called - this method is deprecated'); + logger.warn('TopologyGraphComponent: Topology updates should come from websocket events only'); + // No-op - do not make API calls } handleLoadingState(isLoading) { @@ -691,7 +1049,14 @@ class TopologyGraphComponent extends Component { const container = this.findElement('#topology-graph-container'); if (isLoading) { - container.innerHTML = '
Loading network topology...
'; + // Only show loading state if there's no SVG already rendered + // This prevents clearing the graph during updates + const hasSVG = container.querySelector('svg'); + if (!hasSVG) { + container.innerHTML = '
Loading network topology...
'; + } else { + logger.debug('TopologyGraphComponent: SVG exists, skipping loading state to preserve graph'); + } } } @@ -705,7 +1070,15 @@ class TopologyGraphComponent extends Component { showNoData() { const container = this.findElement('#topology-graph-container'); - container.innerHTML = '
No cluster members found
'; + + // Only show no-data state if there's no SVG already rendered + // This prevents clearing the graph during transient states + const hasSVG = container.querySelector('svg'); + if (!hasSVG) { + container.innerHTML = '
No cluster members found
'; + } else { + logger.debug('TopologyGraphComponent: SVG exists, keeping existing graph visible'); + } } showMemberCardOverlay(nodeData) { @@ -763,12 +1136,17 @@ class TopologyGraphComponent extends Component { } const nodes = this.viewModel.get('nodes'); const links = this.viewModel.get('links'); + const isLoading = this.viewModel.get('isLoading'); + if (nodes && nodes.length > 0) { logger.debug('TopologyGraphComponent: Rendering graph with data'); this.renderGraph(); - } else { - logger.debug('TopologyGraphComponent: No data available, showing loading state'); + } else if (isLoading) { + logger.debug('TopologyGraphComponent: Loading, showing loading state'); this.handleLoadingState(true); + } else { + logger.debug('TopologyGraphComponent: No data available, showing no data state'); + this.showNoData(); } } @@ -785,6 +1163,11 @@ class TopologyGraphComponent extends Component { this.resizeTimeout = null; } + // Clear dragged node positions + if (this.draggedNodePositions) { + this.draggedNodePositions.clear(); + } + // Call parent unmount super.unmount(); } diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index ffff265..deca7ee 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -588,10 +588,68 @@ class TopologyViewModel extends ViewModel { }); } + // Set up WebSocket event listeners for real-time topology updates + setupWebSocketListeners() { + if (!window.wsClient) { + logger.warn('TopologyViewModel: WebSocket client not available'); + return; + } + + // Listen for cluster updates + window.wsClient.on('clusterUpdate', (data) => { + logger.debug('TopologyViewModel: Received WebSocket cluster update:', data); + + // Update topology from WebSocket data + if (data.members && Array.isArray(data.members)) { + logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members`); + + // Build enhanced graph data from updated members + this.buildEnhancedGraphData(data.members).then(({ nodes, links }) => { + this.batchUpdate({ + nodes: nodes, + links: links, + lastUpdateTime: data.timestamp || new Date().toISOString() + }); + }).catch(error => { + logger.error('TopologyViewModel: Failed to build graph data from websocket update:', error); + }); + } else { + logger.warn('TopologyViewModel: Received cluster update but no valid members array:', data); + } + }); + + // Listen for node discovery events + window.wsClient.on('nodeDiscovery', (data) => { + logger.debug('TopologyViewModel: Received WebSocket node discovery event:', data); + + // Node discovery events are logged but no action needed + // The subsequent clusterUpdate event will provide the updated member list + // and update the topology through the websocket data flow + if (data.action === 'discovered') { + logger.debug('TopologyViewModel: Node discovered, waiting for clusterUpdate event'); + } else if (data.action === 'stale') { + logger.debug('TopologyViewModel: Node became stale, waiting for clusterUpdate event'); + } + }); + + // Listen for connection status changes + window.wsClient.on('connected', () => { + logger.debug('TopologyViewModel: WebSocket connected'); + // Connection restored - the server will send a clusterUpdate event shortly + // No need to make an API call, just wait for the websocket data + }); + + window.wsClient.on('disconnected', () => { + logger.debug('TopologyViewModel: WebSocket disconnected'); + }); + } + // Update network topology data + // NOTE: This method makes an API call and should only be used for initial load + // All subsequent updates should come from websocket events (clusterUpdate) async updateNetworkTopology() { try { - logger.debug('TopologyViewModel: updateNetworkTopology called'); + logger.debug('TopologyViewModel: updateNetworkTopology called (API call)'); this.set('isLoading', true); this.set('error', null); @@ -624,11 +682,16 @@ class TopologyViewModel extends ViewModel { async buildEnhancedGraphData(members) { const nodes = []; const links = []; - const nodeConnections = new Map(); + + // Get existing nodes to preserve their positions + const existingNodes = this.get('nodes') || []; + const existingNodeMap = new Map(existingNodes.map(n => [n.id, n])); // Create nodes from members members.forEach((member, index) => { if (member && member.ip) { + const existingNode = existingNodeMap.get(member.ip); + nodes.push({ id: member.ip, hostname: member.hostname || member.ip, @@ -638,75 +701,40 @@ class TopologyViewModel extends ViewModel { // Preserve both legacy 'resources' and preferred 'labels' labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}), resources: member.resources || {}, - x: Math.random() * 1200 + 100, // Better spacing for 1400px width - y: Math.random() * 800 + 100 // Better spacing for 1000px height + // Preserve existing position if node already exists, otherwise assign random position + x: existingNode ? existingNode.x : Math.random() * 1200 + 100, + y: existingNode ? existingNode.y : Math.random() * 800 + 100, + // Preserve velocity if it exists (for D3 simulation) + vx: existingNode ? existingNode.vx : undefined, + vy: existingNode ? existingNode.vy : undefined, + // Preserve fixed position if it was dragged + fx: existingNode ? existingNode.fx : undefined, + fy: existingNode ? existingNode.fy : undefined }); } }); - // Try to get cluster members from each node to build actual connections - for (const node of nodes) { - try { - const nodeResponse = await window.apiClient.getClusterMembersFromNode(node.ip); - if (nodeResponse && nodeResponse.members) { - nodeConnections.set(node.ip, nodeResponse.members); - } - } catch (error) { - console.warn(`Failed to get cluster members from node ${node.ip}:`, error); - // Continue with other nodes - } - } - - // Build links based on actual connections - for (const [sourceIp, sourceMembers] of nodeConnections) { - for (const targetMember of sourceMembers) { - if (targetMember.ip && targetMember.ip !== sourceIp) { - // Check if we already have this link - const existingLink = links.find(link => - (link.source === sourceIp && link.target === targetMember.ip) || - (link.source === targetMember.ip && link.target === sourceIp) - ); - - if (!existingLink) { - const sourceNode = nodes.find(n => n.id === sourceIp); - const targetNode = nodes.find(n => n.id === targetMember.ip); - - if (sourceNode && targetNode) { - const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode); - - links.push({ - source: sourceIp, - target: targetMember.ip, - latency: latency, - sourceNode: sourceNode, - targetNode: targetNode, - bidirectional: true - }); - } - } - } - } - } - - // If no actual connections found, create a basic mesh - if (links.length === 0) { - logger.debug('TopologyViewModel: No actual connections found, creating basic mesh'); - for (let i = 0; i < nodes.length; i++) { - for (let j = i + 1; j < nodes.length; j++) { - const sourceNode = nodes[i]; - const targetNode = nodes[j]; - - const estimatedLatency = this.estimateLatency(sourceNode, targetNode); - - links.push({ - source: sourceNode.id, - target: targetNode.id, - latency: estimatedLatency, - sourceNode: sourceNode, - targetNode: targetNode, - bidirectional: true - }); - } + // Build links - create a mesh topology from the members data + // All connections are inferred from the cluster membership + // No additional API calls needed - all data comes from websocket updates + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const sourceNode = nodes[i]; + const targetNode = nodes[j]; + + // Use the latency from the member data if available, otherwise estimate + const sourceMember = members.find(m => m.ip === sourceNode.ip); + const targetMember = members.find(m => m.ip === targetNode.ip); + const latency = targetMember?.latency || sourceMember?.latency || this.estimateLatency(sourceNode, targetNode); + + links.push({ + source: sourceNode.id, + target: targetNode.id, + latency: latency, + sourceNode: sourceNode, + targetNode: targetNode, + bidirectional: true + }); } }