// Topology Graph Component with D3.js force-directed visualization class TopologyGraphComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); logger.debug('TopologyGraphComponent: Constructor called'); this.svg = null; this.simulation = null; this.zoom = null; this.width = 0; // Will be set dynamically based on container size this.height = 0; // Will be set dynamically based on container size this.isInitialized = false; // Drawer state for desktop reuse (shared singleton) this.drawer = new DrawerComponent(); // 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 isDesktop() { return this.drawer.isDesktop(); } openDrawerForNode(nodeData) { // Get display name for drawer title let displayName = 'Node Details'; try { const hostname = nodeData.hostname || ''; const ip = nodeData.ip || ''; if (hostname && ip) { displayName = `${hostname} - ${ip}`; } else if (hostname) { displayName = hostname; } else if (ip) { displayName = ip; } } catch (_) {} // Open drawer with content callback this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => { // Mount NodeDetailsComponent const nodeDetailsVM = new NodeDetailsViewModel(); const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus); setActiveComponent(nodeDetailsComponent); const ip = nodeData.ip || nodeData.id; nodeDetailsVM.loadNodeDetails(ip).then(() => { nodeDetailsComponent.mount(); }).catch((error) => { logger.error('Failed to load node details (topology drawer):', error); contentContainer.innerHTML = `
Error loading node details:
${this.escapeHtml(error.message)}
`; }); }); try { if (window.TerminalPanel && typeof nodeData.ip === 'string') { window.TerminalPanel.lastNodeIp = nodeData.ip; if (window.TerminalPanel._updateTitle) { window.TerminalPanel._updateTitle(nodeData.ip); } } } catch (_) {} } closeDrawer() { this.drawer.closeDrawer(); } // Tooltip helpers ensureTooltip() { if (this.tooltipEl) return; const el = document.createElement('div'); el.className = 'topology-tooltip'; document.body.appendChild(el); this.tooltipEl = el; } showTooltip(nodeData, pageX, pageY) { this.ensureTooltip(); const labels = (nodeData && nodeData.labels) ? nodeData.labels : ((nodeData && nodeData.resources) ? nodeData.resources : null); if (!labels || Object.keys(labels).length === 0) { this.hideTooltip(); return; } const chips = Object.entries(labels) .map(([k, v]) => `${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}`) .join(''); // Add hint for node interactions let hint = '
💡 Click to set as primary & toggle view
'; if (nodeData && nodeData.isPrimary) { hint = '
⭐ Primary Node - Click to toggle view
'; } this.tooltipEl.innerHTML = `
${chips}
${hint}`; this.positionTooltip(pageX, pageY); this.tooltipEl.classList.add('visible'); } positionTooltip(pageX, pageY) { if (!this.tooltipEl) return; const offset = 12; let left = pageX + offset; let top = pageY + offset; const { innerWidth, innerHeight } = window; const rect = this.tooltipEl.getBoundingClientRect(); if (left + rect.width > innerWidth - 8) left = pageX - rect.width - offset; if (top + rect.height > innerHeight - 8) top = pageY - rect.height - offset; this.tooltipEl.style.left = `${Math.max(8, left)}px`; this.tooltipEl.style.top = `${Math.max(8, top)}px`; } moveTooltip(pageX, pageY) { if (!this.tooltipEl || !this.tooltipEl.classList.contains('visible')) return; this.positionTooltip(pageX, pageY); } hideTooltip() { if (this.tooltipEl) this.tooltipEl.classList.remove('visible'); } updateDimensions(container) { // Get the container's actual dimensions const rect = container.getBoundingClientRect(); this.width = rect.width || 1400; // Fallback to 1400 if width is 0 this.height = rect.height || 1000; // Fallback to 1000 if height is 0 // Ensure minimum dimensions this.width = Math.max(this.width, 800); this.height = Math.max(this.height, 600); logger.debug('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height); } handleResize() { // Debounce resize events to avoid excessive updates if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); } this.resizeTimeout = setTimeout(() => { const container = this.findElement('#topology-graph-container'); if (container && this.svg) { this.updateDimensions(container); // Update SVG viewBox and force center this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`); if (this.simulation) { this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2)); this.simulation.alpha(0.3).restart(); } } }, 250); // 250ms debounce } // Override mount to ensure proper initialization mount() { if (this.isMounted) return; logger.debug('TopologyGraphComponent: Starting mount...'); logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); // Call initialize if not already done if (!this.isInitialized) { logger.debug('TopologyGraphComponent: Initializing during mount...'); this.initialize().then(() => { logger.debug('TopologyGraphComponent: Initialization completed, calling completeMount...'); // Complete mount after initialization this.completeMount(); }).catch(error => { logger.error('TopologyGraphComponent: Initialization failed during mount:', error); // Still complete mount to prevent blocking this.completeMount(); }); } else { logger.debug('TopologyGraphComponent: Already initialized, calling completeMount directly...'); this.completeMount(); } } completeMount() { logger.debug('TopologyGraphComponent: completeMount called'); this.isMounted = true; logger.debug('TopologyGraphComponent: Setting up event listeners...'); this.setupEventListeners(); logger.debug('TopologyGraphComponent: Setting up view model listeners...'); this.setupViewModelListeners(); logger.debug('TopologyGraphComponent: Calling render...'); this.render(); logger.debug('TopologyGraphComponent: Mounted successfully'); } setupEventListeners() { logger.debug('TopologyGraphComponent: setupEventListeners called'); logger.debug('TopologyGraphComponent: Container:', this.container); logger.debug('TopologyGraphComponent: Container ID:', this.container?.id); // Add resize listener to update dimensions when window is resized this.resizeHandler = this.handleResize.bind(this); window.addEventListener('resize', this.resizeHandler); // Refresh button removed from HTML, so no need to set up event listeners logger.debug('TopologyGraphComponent: No event listeners needed (refresh button removed)'); } setupViewModelListeners() { logger.debug('TopologyGraphComponent: setupViewModelListeners called'); logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized); if (this.isInitialized) { // Component is already initialized, set up subscriptions immediately logger.debug('TopologyGraphComponent: Setting up property subscriptions immediately'); this.subscribeToProperty('nodes', this.renderGraph.bind(this)); this.subscribeToProperty('links', this.renderGraph.bind(this)); this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this)); this.subscribeToProperty('error', this.handleError.bind(this)); this.subscribeToProperty('selectedNode', this.updateSelection.bind(this)); this.subscribeToProperty('discoveryEvent', this.handleDiscoveryEvent.bind(this)); } else { // Component not yet initialized, store for later logger.debug('TopologyGraphComponent: Component not initialized, storing pending subscriptions'); this._pendingSubscriptions = [ ['nodes', this.renderGraph.bind(this)], ['links', this.renderGraph.bind(this)], ['isLoading', this.handleLoadingState.bind(this)], ['error', this.handleError.bind(this)], ['selectedNode', this.updateSelection.bind(this)], ['discoveryEvent', this.handleDiscoveryEvent.bind(this)] ]; } } async initialize() { logger.debug('TopologyGraphComponent: Initializing...'); // Wait for DOM to be ready if (document.readyState === 'loading') { await new Promise(resolve => { document.addEventListener('DOMContentLoaded', resolve); }); } // Set up the SVG container this.setupSVG(); // Mark as initialized this.isInitialized = true; // Now set up the actual property listeners after initialization if (this._pendingSubscriptions) { this._pendingSubscriptions.forEach(([property, callback]) => { this.subscribeToProperty(property, callback); }); 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) { logger.error('TopologyGraphComponent: Graph container not found'); return; } // Calculate dynamic dimensions based on container size this.updateDimensions(container); // 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(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('border-radius', '12px'); // Add zoom behavior this.zoom = d3.zoom() .scaleExtent([0.5, 5]) .on('zoom', (event) => { this.svg.select('g').attr('transform', event.transform); }); this.svg.call(this.zoom); // Create main group for zoom and apply initial zoom const mainGroup = this.svg.append('g'); // Apply initial zoom to show the graph more zoomed in mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); 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) { logger.debug('TopologyGraphComponent: Ensuring initialization...'); await this.initialize(); } return this.isInitialized; } renderGraph() { try { // Check if component is initialized if (!this.isInitialized) { logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...'); this.ensureInitialized().then(() => { // Re-render after initialization this.renderGraph(); }).catch(error => { logger.error('TopologyGraphComponent: Failed to initialize:', error); }); return; } 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'); this.setupSVG(); } if (!nodes || nodes.length === 0) { this.showNoData(); return; } logger.debug('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links'); // Get the main SVG group (the one created in setupSVG) let svgGroup = this.svg.select('g'); if (!svgGroup || svgGroup.empty()) { logger.debug('TopologyGraphComponent: Creating new SVG group'); svgGroup = this.svg.append('g'); // Apply initial zoom to show the graph more zoomed in svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); } // 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) { logger.error('Failed to render graph:', error); } } 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; // For directional links, maintain source -> target order in the key // For bidirectional links, use consistent ordering to avoid duplicates if (d.bidirectional) { return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`; } else { return `${sourceId}->${targetId}`; } }); // Remove old links link.exit() .transition() .duration(600) .ease(d3.easeCubicOut) .style('opacity', 0) .remove(); // Add new links const linkEnter = link.enter().append('line') .attr('stroke-opacity', 0); // Merge and update all links const linkMerge = linkEnter.merge(link) .transition() .duration(600) .ease(d3.easeCubicInOut) .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', d => d.isPrimary ? '#FFD700' : '#fff') .attr('stroke-width', d => d.isPrimary ? 3 : 2); // Status indicator - removed // Primary node badge const primaryBadge = nodeEnter.filter(d => d.isPrimary) .append('g') .attr('class', 'primary-badge') .attr('transform', 'translate(0, 0)'); primaryBadge.append('text') .text('P') .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') .attr('font-size', '12px') .attr('font-weight', 'bold') .attr('fill', '#000'); // 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)) .attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff') .attr('stroke-width', d => d.isPrimary ? 3 : 2); // Update status indicator - removed 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)); // Update primary badge for existing nodes // Remove badge from nodes that are no longer primary nodeMerge.filter(d => !d.isPrimary) .select('.primary-badge') .remove(); // Add badge to nodes that became primary (if they don't already have one) nodeMerge.filter(d => d.isPrimary) .each(function(d) { const nodeGroup = d3.select(this); if (nodeGroup.select('.primary-badge').empty()) { const badge = nodeGroup.append('g') .attr('class', 'primary-badge') .attr('transform', 'translate(0, 0)'); badge.append('text') .text('P') .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') .attr('font-size', '12px') .attr('font-weight', 'bold') .attr('fill', '#000'); } }); // 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', async (event, d) => { event.stopPropagation(); // Check if we're toggling back to mesh (clicking same center node in star mode) const currentMode = this.viewModel.get('topologyMode'); const currentCenter = this.viewModel.get('starCenterNode'); const togglingToMesh = currentMode === 'star' && currentCenter === d.ip; // Toggle topology mode - switch between mesh and star await this.viewModel.toggleTopologyMode(d.ip); // Also switch to this node as primary (if not already) if (!d.isPrimary) { try { logger.info(`TopologyGraphComponent: Switching primary to ${d.ip}`); await this.viewModel.switchToPrimaryNode(d.ip); } catch (error) { logger.error('TopologyGraphComponent: Failed to switch primary:', error); } } // Open or close drawer based on mode toggle if (togglingToMesh) { // Switching back to mesh - close drawer this.viewModel.clearSelection(); if (this.isDesktop()) { this.closeDrawer(); } } else { // Switching to star or changing center - open drawer 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) { const topologyMode = this.viewModel.get('topologyMode') || 'mesh'; // Only show latency labels in star mode if (topologyMode !== 'star') { // Clear any existing labels in mesh mode const linkLabelGroup = svgGroup.select('.link-label-group'); if (!linkLabelGroup.empty()) { linkLabelGroup.selectAll('text').remove(); } this.linkLabelSelection = null; return; } // Star mode - show latency labels // 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)); // Check if nodes changed const nodesChanged = currentNodes.length !== nodes.length || [...newNodeIds].some(id => !currentNodeIds.has(id)) || [...currentNodeIds].some(id => !newNodeIds.has(id)); // Check if links changed (compare link keys) const currentLinks = this.simulation.force('link').links(); const currentLinkKeys = new Set(currentLinks.map(l => { const sourceId = typeof l.source === 'object' ? l.source.id : l.source; const targetId = typeof l.target === 'object' ? l.target.id : l.target; return `${sourceId}->${targetId}`; })); const newLinkKeys = new Set(links.map(l => { const sourceId = typeof l.source === 'object' ? l.source.id : l.source; const targetId = typeof l.target === 'object' ? l.target.id : l.target; return `${sourceId}->${targetId}`; })); const linksChanged = currentLinks.length !== links.length || [...newLinkKeys].some(key => !currentLinkKeys.has(key)) || [...currentLinkKeys].some(key => !newLinkKeys.has(key)); const isStructuralChange = nodesChanged || linksChanged; // Update simulation data this.simulation.nodes(nodes); this.simulation.force('link').links(links); if (isStructuralChange) { if (linksChanged && !nodesChanged) { // Only links changed (e.g., primary node switch) // Keep nodes in place, just update link positions logger.debug('TopologyGraphComponent: Link structure changed (primary switch), keeping node positions'); // Stop the simulation completely this.simulation.stop(); // Trigger a single tick to update link positions with the new data // This ensures D3's forceLink properly connects sources and targets this.simulation.tick(); // Manually update the visual positions immediately 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); } 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); } // Keep simulation stopped - nodes won't move } else if (nodesChanged) { // Nodes added/removed: use moderate restart logger.debug('TopologyGraphComponent: Node structure changed, moderate restart'); this.simulation.alpha(0.3).restart(); } else { // Just links changed with same nodes logger.debug('TopologyGraphComponent: Link structure changed, restarting simulation'); this.simulation.alpha(0.2).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) { // Remove existing legend to recreate with current mode svgGroup.select('.legend-group').remove(); const legend = svgGroup.append('g') .attr('class', 'legend-group graph-element') .attr('transform', `translate(120, 120)`) // Hidden by CSS opacity .style('opacity', '0'); legend.append('rect') .attr('width', 420) .attr('height', 140) .attr('fill', 'rgba(0, 0, 0, 0.7)') .attr('rx', 8) .attr('stroke', 'rgba(255, 255, 255, 0.2)') .attr('stroke-width', 1); const nodeLegend = legend.append('g') .attr('transform', 'translate(20, 20)'); nodeLegend.append('text') .text('Node Status:') .attr('x', 0) .attr('y', 0) .attr('font-size', '14px') .attr('font-weight', '600') .attr('fill', '#ecf0f1'); const statuses = [ { status: 'ACTIVE', color: '#10b981', y: 20 }, { status: 'INACTIVE', color: '#f59e0b', y: 40 }, { status: 'DEAD', color: '#ef4444', y: 60 } ]; statuses.forEach(item => { nodeLegend.append('circle') .attr('r', 6) .attr('cx', 0) .attr('cy', item.y) .attr('fill', item.color); nodeLegend.append('text') .text(item.status) .attr('x', 15) .attr('y', item.y + 4) .attr('font-size', '12px') .attr('fill', '#ecf0f1'); }); const linkLegend = legend.append('g') .attr('transform', 'translate(150, 20)'); linkLegend.append('text') .text('Link Latency:') .attr('x', 0) .attr('y', 0) .attr('font-size', '14px') .attr('font-weight', '600') .attr('fill', '#ecf0f1'); const latencies = [ { range: '≤30ms', color: '#10b981', y: 20 }, { range: '31-50ms', color: '#f59e0b', y: 40 }, { range: '>50ms', color: '#ef4444', y: 60 } ]; latencies.forEach(item => { linkLegend.append('line') .attr('x1', 0) .attr('y1', item.y) .attr('x2', 20) .attr('y2', item.y) .attr('stroke', item.color) .attr('stroke-width', 2); linkLegend.append('text') .text(item.range) .attr('x', 25) .attr('y', item.y + 4) .attr('font-size', '12px') .attr('fill', '#ecf0f1'); }); const topologyLegend = legend.append('g') .attr('transform', 'translate(280, 20)'); topologyLegend.append('text') .text('Topology:') .attr('x', 0) .attr('y', 0) .attr('font-size', '14px') .attr('font-weight', '600') .attr('fill', '#ecf0f1'); // Primary node indicator const primaryBadge = topologyLegend.append('g') .attr('transform', 'translate(0, 20)'); primaryBadge.append('circle') .attr('r', 6) .attr('fill', '#FFD700') .attr('stroke', '#FFF') .attr('stroke-width', 1); primaryBadge.append('text') .text('P') .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') .attr('font-size', '8px') .attr('font-weight', 'bold') .attr('fill', '#000'); topologyLegend.append('text') .text('Primary Node') .attr('x', 15) .attr('y', 24) .attr('font-size', '12px') .attr('fill', '#ecf0f1'); // Topology mode info const topologyMode = this.viewModel.get('topologyMode') || 'mesh'; const modeText = topologyMode === 'mesh' ? 'Full Mesh' : 'Star Topology'; const modeDesc = topologyMode === 'mesh' ? '(All to All)' : '(Center to Others)'; topologyLegend.append('text') .text(modeText) .attr('x', 0) .attr('y', 50) .attr('font-size', '12px') .attr('fill', '#ecf0f1'); topologyLegend.append('text') .text(modeDesc) .attr('x', 0) .attr('y', 65) .attr('font-size', '10px') .attr('fill', 'rgba(236, 240, 241, 0.7)'); } getNodeRadius(status) { switch (status?.toUpperCase()) { case 'ACTIVE': return 10; case 'INACTIVE': return 8; case 'DEAD': return 6; default: return 8; } } getStatusIndicatorColor(status) { switch (status?.toUpperCase()) { case 'ACTIVE': return '#10b981'; case 'INACTIVE': return '#f59e0b'; case 'DEAD': return '#ef4444'; default: return '#6b7280'; } } getLinkColor(latency) { if (latency <= 50) return '#10b981'; if (latency <= 100) return '#f59e0b'; return '#ef4444'; } getNodeColor(status) { switch (status?.toUpperCase()) { case 'ACTIVE': return '#10b981'; case 'INACTIVE': return '#f59e0b'; case 'DEAD': return '#ef4444'; default: return '#6b7280'; } } drag(simulation) { return d3.drag() .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', (event, d) => { d.fx = event.x; d.fy = event.y; }) .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) { return; } this.svg.selectAll('.node').select('circle') .attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2) .attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff'); } handleDiscoveryEvent(discoveryEvent) { if (!discoveryEvent || !discoveryEvent.nodeIp) { return; } // Emit discovery dots from the discovered node to all other nodes this.emitDiscoveryDots(discoveryEvent.nodeIp); } emitDiscoveryDots(sourceNodeIp) { if (!this.svg || !this.isInitialized) { return; } const nodes = this.viewModel.get('nodes') || []; const sourceNode = nodes.find(n => n.ip === sourceNodeIp); if (!sourceNode || !sourceNode.x || !sourceNode.y) { return; } // Get the main graph group (that has the transform applied) const mainGroup = this.svg.select('g'); // Get or create animation group inside the main transformed group let animGroup = mainGroup.select('.discovery-animation-group'); if (animGroup.empty()) { animGroup = mainGroup.append('g') .attr('class', 'discovery-animation-group') .style('pointer-events', 'none'); // Make entire animation group non-interactive } // Emit a dot to each other node nodes.forEach(targetNode => { if (targetNode.ip !== sourceNodeIp && targetNode.x && targetNode.y) { this.animateDiscoveryDot(animGroup, sourceNode, targetNode); } }); } animateDiscoveryDot(animGroup, sourceNode, targetNode) { // Calculate spawn position outside the node boundary const nodeRadius = this.getNodeRadius(sourceNode.status); const spawnDistance = nodeRadius + 8; // Spawn 8px outside the node // Calculate direction from source to target const dx = targetNode.x - sourceNode.x; const dy = targetNode.y - sourceNode.y; const distance = Math.sqrt(dx * dx + dy * dy); // Normalize direction and spawn outside the node const normalizedX = dx / distance; const normalizedY = dy / distance; const spawnX = sourceNode.x + normalizedX * spawnDistance; const spawnY = sourceNode.y + normalizedY * spawnDistance; // Create a small circle outside the source node const dot = animGroup.append('circle') .attr('class', 'discovery-dot') .attr('r', 6) .attr('cx', spawnX) .attr('cy', spawnY) .attr('fill', '#FFD700') .attr('opacity', 1) .attr('stroke', '#FFF') .attr('stroke-width', 2) .style('pointer-events', 'none'); // Make dots non-interactive // Trigger response dot early (after 70% of the journey) - only if target node is active setTimeout(() => { if (targetNode.status && targetNode.status.toUpperCase() === 'ACTIVE') { this.animateResponseDot(animGroup, targetNode, sourceNode); } }, 1050); // 1500ms * 0.7 = 1050ms // Animate the dot to the target node dot.transition() .duration(1500) .ease(d3.easeCubicInOut) .attr('cx', targetNode.x) .attr('cy', targetNode.y) .attr('opacity', 0) .remove(); } animateResponseDot(animGroup, sourceNode, targetNode) { // Calculate spawn position outside the target node boundary const nodeRadius = this.getNodeRadius(sourceNode.status); const spawnDistance = nodeRadius + 8; // Spawn 8px outside the node // Calculate direction from target back to original source const dx = targetNode.x - sourceNode.x; const dy = targetNode.y - sourceNode.y; const distance = Math.sqrt(dx * dx + dy * dy); // Normalize direction and spawn outside the node const normalizedX = dx / distance; const normalizedY = dy / distance; const spawnX = sourceNode.x + normalizedX * spawnDistance; const spawnY = sourceNode.y + normalizedY * spawnDistance; // Calculate target position outside the original source node const targetNodeRadius = this.getNodeRadius(targetNode.status); const targetDistance = targetNodeRadius + 8; // Stop 8px outside the target node // Calculate direction from source back to target const targetDx = sourceNode.x - targetNode.x; const targetDy = sourceNode.y - targetNode.y; const targetNormalizedX = targetDx / distance; const targetNormalizedY = targetDy / distance; const targetX = targetNode.x + targetNormalizedX * targetDistance; const targetY = targetNode.y + targetNormalizedY * targetDistance; // Create a response dot outside the target (now source) node const responseDot = animGroup.append('circle') .attr('class', 'discovery-dot-response') .attr('r', 5) .attr('cx', spawnX) .attr('cy', spawnY) .attr('fill', '#2196F3') .attr('opacity', 1) .attr('stroke', '#FFF') .attr('stroke-width', 2) .style('pointer-events', 'none'); // Make dots non-interactive // Animate back to outside the original source node responseDot.transition() .duration(1000) .ease(d3.easeCubicInOut) .attr('cx', targetX) .attr('cy', targetY) .attr('opacity', 0) .remove(); } // 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.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) { logger.debug('TopologyGraphComponent: handleLoadingState called with:', isLoading); const container = this.findElement('#topology-graph-container'); if (isLoading) { // 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'); } } } handleError() { const error = this.viewModel.get('error'); if (error) { const container = this.findElement('#topology-graph-container'); container.innerHTML = `
Error: ${error}
`; } } showNoData() { const container = this.findElement('#topology-graph-container'); // 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) { // Create overlay container if it doesn't exist let overlayContainer = document.getElementById('member-card-overlay'); if (!overlayContainer) { overlayContainer = document.createElement('div'); overlayContainer.id = 'member-card-overlay'; overlayContainer.className = 'member-card-overlay'; document.body.appendChild(overlayContainer); } // Create and show the overlay component if (!this.memberOverlayComponent) { const overlayVM = new ViewModel(); this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus); this.memberOverlayComponent.mount(); } // Convert node data to member data format const memberData = { ip: nodeData.ip, hostname: nodeData.hostname, status: this.normalizeStatus(nodeData.status), latency: nodeData.latency, labels: (nodeData.labels && typeof nodeData.labels === 'object') ? nodeData.labels : (nodeData.resources || {}) }; this.memberOverlayComponent.show(memberData); } // Normalize status from topology format to member card format normalizeStatus(status) { if (!status) return 'unknown'; const normalized = status.toLowerCase(); switch (normalized) { case 'active': return 'active'; case 'inactive': return 'inactive'; case 'dead': return 'offline'; default: return 'unknown'; } } // Override render method to display the graph render() { logger.debug('TopologyGraphComponent: render called'); if (!this.isInitialized) { logger.debug('TopologyGraphComponent: Not initialized yet, skipping render'); return; } 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 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(); } } unmount() { // Clean up resize listener if (this.resizeHandler) { window.removeEventListener('resize', this.resizeHandler); this.resizeHandler = null; } // Clear resize timeout if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); this.resizeTimeout = null; } // Clear dragged node positions if (this.draggedNodePositions) { this.draggedNodePositions.clear(); } // Call parent unmount super.unmount(); } } // Minimal Member Card Overlay Component (kept in same file to avoid circular loads) class MemberCardOverlayComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); this.isVisible = false; this.currentMember = null; } mount() { super.mount(); this.setupEventListeners(); } setupEventListeners() { // Close overlay when clicking outside or pressing escape this.addEventListener(this.container, 'click', (e) => { if (!this.isVisible) return; if (e.target === this.container) { this.hide(); } }); this.addEventListener(document, 'keydown', (e) => { if (e.key === 'Escape' && this.isVisible) { this.hide(); } }); } show(memberData) { this.currentMember = memberData; this.isVisible = true; const memberCardHTML = this.renderMemberCard(memberData); this.setHTML('', memberCardHTML); setTimeout(() => { this.container.classList.add('visible'); }, 10); this.setupMemberCardInteractions(); } hide() { this.isVisible = false; this.container.classList.remove('visible'); this.currentMember = null; } renderMemberCard(member) { const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : (member.status && member.status.toUpperCase() === 'INACTIVE') ? 'status-inactive' : 'status-offline'; const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' : (member.status && member.status.toUpperCase() === 'INACTIVE') ? '🟠' : '🔴'; return `
${statusIcon}
${member.hostname || 'Unknown Device'}
${member.ip || 'No IP'}
Latency: ${member.latency ? member.latency + 'ms' : 'N/A'}
Loading detailed information...
`; } setupMemberCardInteractions() { const closeBtn = this.findElement('.member-overlay-close'); if (closeBtn) { this.addEventListener(closeBtn, 'click', () => { this.hide(); }); } setTimeout(async () => { const memberCard = this.findElement('.member-card'); if (memberCard) { const memberDetails = memberCard.querySelector('.member-details'); const memberIp = memberCard.dataset.memberIp; await this.expandCard(memberCard, memberIp, memberDetails); } }, 100); } async expandCard(card, memberIp, memberDetails) { try { const nodeDetailsVM = new NodeDetailsViewModel(); const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus); await nodeDetailsVM.loadNodeDetails(memberIp); const nodeStatus = nodeDetailsVM.get('nodeStatus'); if (nodeStatus && nodeStatus.labels) { const labelsContainer = document.querySelector('.member-overlay-header .member-labels'); if (labelsContainer) { labelsContainer.innerHTML = Object.entries(nodeStatus.labels) .map(([key, value]) => `${key}: ${value}`) .join(''); labelsContainer.style.display = 'block'; } } nodeDetailsComponent.mount(); card.classList.add('expanded'); } catch (error) { logger.error('Failed to expand member card:', error); card.classList.add('expanded'); const details = card.querySelector('.member-details'); if (details) { details.innerHTML = '
Failed to load node details
'; } } } } window.TopologyGraphComponent = TopologyGraphComponent; window.MemberCardOverlayComponent = MemberCardOverlayComponent;