// 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 (same pattern as ClusterMembersComponent) this.detailsDrawer = null; this.detailsDrawerContent = null; this.detailsDrawerBackdrop = null; this.activeDrawerComponent = null; // Tooltip for labels on hover this.tooltipEl = null; } // Determine desktop threshold isDesktop() { try { return window && window.innerWidth >= 1024; } catch (_) { return false; } } ensureDrawer() { if (this.detailsDrawer) return; // Backdrop this.detailsDrawerBackdrop = document.createElement('div'); this.detailsDrawerBackdrop.className = 'details-drawer-backdrop'; document.body.appendChild(this.detailsDrawerBackdrop); // Drawer this.detailsDrawer = document.createElement('div'); this.detailsDrawer.className = 'details-drawer'; const header = document.createElement('div'); header.className = 'details-drawer-header'; header.innerHTML = `
Node Details
`; this.detailsDrawer.appendChild(header); this.detailsDrawerContent = document.createElement('div'); this.detailsDrawerContent.className = 'details-drawer-content'; this.detailsDrawer.appendChild(this.detailsDrawerContent); document.body.appendChild(this.detailsDrawer); const close = () => this.closeDrawer(); header.querySelector('.drawer-close').addEventListener('click', close); this.detailsDrawerBackdrop.addEventListener('click', close); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }); } openDrawerForNode(nodeData) { this.ensureDrawer(); // Title from hostname or IP try { const displayName = nodeData.hostname || nodeData.ip || 'Node Details'; const titleEl = this.detailsDrawer.querySelector('.drawer-title'); if (titleEl) titleEl.textContent = displayName; } catch (_) {} // Clear previous component if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') { try { this.activeDrawerComponent.unmount(); } catch (_) {} } this.detailsDrawerContent.innerHTML = '
Loading detailed information...
'; // Mount NodeDetailsComponent const nodeDetailsVM = new NodeDetailsViewModel(); const nodeDetailsComponent = new NodeDetailsComponent(this.detailsDrawerContent, nodeDetailsVM, this.eventBus); this.activeDrawerComponent = 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); this.detailsDrawerContent.innerHTML = `
Error loading node details:
${this.escapeHtml(error.message)}
`; }); // Open this.detailsDrawer.classList.add('open'); this.detailsDrawerBackdrop.classList.add('visible'); } closeDrawer() { if (this.detailsDrawer) this.detailsDrawer.classList.remove('open'); if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible'); } // 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(''); this.tooltipEl.innerHTML = `
${chips}
`; 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)); } 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)] ]; } } 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; } // Initial data load await this.viewModel.updateNetworkTopology(); } 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 SVG element this.svg = d3.select(container) .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 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'); } // 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'); // 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)'); } // 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); }); this.addLegend(svgGroup); } catch (error) { logger.error('Failed to render graph:', error); } } addLegend(svgGroup) { const legend = svgGroup.append('g') .attr('class', 'graph-element') .attr('transform', `translate(120, 120)`) // Hidden by CSS opacity .style('opacity', '0'); legend.append('rect') .attr('width', 320) .attr('height', 120) .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'); }); } 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 <= 30) return '#10b981'; if (latency <= 50) 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', function(event, d) { if (!event.active && simulation && simulation.alphaTarget) { simulation.alphaTarget(0.3).restart(); } d.fx = d.x; d.fy = d.y; }) .on('drag', function(event, d) { d.fx = event.x; d.fy = event.y; }) .on('end', function(event, d) { if (!event.active && simulation && simulation.alphaTarget) { simulation.alphaTarget(0); } d.fx = null; d.fy = null; }); } 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'); } 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(); } handleLoadingState(isLoading) { logger.debug('TopologyGraphComponent: handleLoadingState called with:', isLoading); const container = this.findElement('#topology-graph-container'); if (isLoading) { container.innerHTML = '
Loading network topology...
'; } } 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'); container.innerHTML = '
No cluster members found
'; } 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'); if (nodes && nodes.length > 0) { logger.debug('TopologyGraphComponent: Rendering graph with data'); this.renderGraph(); } else { logger.debug('TopologyGraphComponent: No data available, showing loading state'); this.handleLoadingState(true); } } 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; } // 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 === 'active' ? 'status-online' : member.status === 'inactive' ? 'status-inactive' : 'status-offline'; const statusIcon = member.status === 'active' ? '🟢' : member.status === '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;