diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js index ea366c0..3b5505c 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,345 @@ 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 + const link = linkGroup.selectAll('line') + .data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`); + + // 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 + const linkLabels = linkLabelGroup.selectAll('text') + .data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`); + + // 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 +932,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 +952,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) { @@ -691,7 +1048,14 @@ class TopologyGraphComponent extends Component { const container = this.findElement('#topology-graph-container'); if (isLoading) { - container.innerHTML = '