From 5850931614c845db0330e3e5540449d2144714bd Mon Sep 17 00:00:00 2001 From: 0x1d Date: Sat, 25 Oct 2025 11:39:56 +0200 Subject: [PATCH] feat: toggle between mesh and star topology --- .../components/TopologyGraphComponent.js | 222 ++++++++++-------- public/scripts/view-models.js | 101 ++++++-- 2 files changed, 199 insertions(+), 124 deletions(-) diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js index bec4aa4..6065da3 100644 --- a/public/scripts/components/TopologyGraphComponent.js +++ b/public/scripts/components/TopologyGraphComponent.js @@ -99,12 +99,10 @@ class TopologyGraphComponent extends Component { .map(([k, v]) => `${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}`) .join(''); - // Add hint for non-primary nodes - let hint = ''; - if (nodeData && !nodeData.isPrimary) { - hint = '
💡 Click to switch to primary & view details
'; - } else if (nodeData && nodeData.isPrimary) { - hint = '
⭐ Primary Node - Click to view details
'; + // 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}`; @@ -622,30 +620,19 @@ class TopologyGraphComponent extends Component { .attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff') .attr('stroke-width', d => d.isPrimary ? 3 : 2); - // Status indicator - nodeEnter.append('circle') - .attr('r', 3) - .attr('fill', d => this.getStatusIndicatorColor(d.status)) - .attr('cx', -8) - .attr('cy', -8); + // Status indicator - removed // Primary node badge const primaryBadge = nodeEnter.filter(d => d.isPrimary) .append('g') .attr('class', 'primary-badge') - .attr('transform', 'translate(8, -8)'); - - primaryBadge.append('circle') - .attr('r', 8) - .attr('fill', '#FFD700') - .attr('stroke', '#FFF') - .attr('stroke-width', 1); + .attr('transform', 'translate(0, 0)'); primaryBadge.append('text') .text('P') .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') - .attr('font-size', '10px') + .attr('font-size', '12px') .attr('font-weight', 'bold') .attr('fill', '#000'); @@ -701,10 +688,7 @@ class TopologyGraphComponent extends Component { .attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff') .attr('stroke-width', d => d.isPrimary ? 3 : 2); - nodeMerge.select('circle:nth-child(2)') - .transition() - .duration(300) - .attr('fill', d => this.getStatusIndicatorColor(d.status)); + // Update status indicator - removed nodeMerge.select('.hostname-text') .text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname); @@ -735,19 +719,13 @@ class TopologyGraphComponent extends Component { if (nodeGroup.select('.primary-badge').empty()) { const badge = nodeGroup.append('g') .attr('class', 'primary-badge') - .attr('transform', 'translate(8, -8)'); - - badge.append('circle') - .attr('r', 8) - .attr('fill', '#FFD700') - .attr('stroke', '#FFF') - .attr('stroke-width', 1); + .attr('transform', 'translate(0, 0)'); badge.append('text') .text('P') .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') - .attr('font-size', '10px') + .attr('font-size', '12px') .attr('font-weight', 'bold') .attr('fill', '#000'); } @@ -769,59 +747,39 @@ class TopologyGraphComponent extends Component { .on('click', async (event, d) => { event.stopPropagation(); - // Always open drawer/details for the clicked node - this.viewModel.selectNode(d.id); - this.updateSelection(d.id); - if (this.isDesktop()) { - this.openDrawerForNode(d); - } else { - this.showMemberCardOverlay(d); - } + // 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; - // If clicking on non-primary node, also switch it to primary + // 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 { - // Visual feedback - highlight the clicked node - d3.select(event.currentTarget).select('circle') - .transition() - .duration(200) - .attr('stroke-width', 4) - .attr('stroke', '#FFD700'); - logger.info(`TopologyGraphComponent: Switching primary to ${d.ip}`); - - // Switch to this node as primary await this.viewModel.switchToPrimaryNode(d.ip); - - // Show success feedback briefly - this.showTooltip({ - ...d, - hostname: `✓ Switched to ${d.hostname}` - }, event.pageX, event.pageY); - - setTimeout(() => { - this.hideTooltip(); - }, 1500); - } catch (error) { logger.error('TopologyGraphComponent: Failed to switch primary:', error); - - // Show error feedback - this.showTooltip({ - ...d, - hostname: `✗ Failed: ${error.message}` - }, event.pageX, event.pageY); - - setTimeout(() => { - this.hideTooltip(); - }, 3000); - - // Revert visual feedback - d3.select(event.currentTarget).select('circle') - .transition() - .duration(200) - .attr('stroke-width', 2) - .attr('stroke', '#fff'); + } + } + + // 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); } } }) @@ -843,6 +801,20 @@ class TopologyGraphComponent extends Component { } 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()) { @@ -1005,10 +977,8 @@ class TopologyGraphComponent extends Component { } addLegend(svgGroup) { - // Only add legend if it doesn't exist - if (!svgGroup.select('.legend-group').empty()) { - return; - } + // Remove existing legend to recreate with current mode + svgGroup.select('.legend-group').remove(); const legend = svgGroup.append('g') .attr('class', 'legend-group graph-element') @@ -1125,16 +1095,20 @@ class TopologyGraphComponent extends Component { .attr('font-size', '12px') .attr('fill', '#ecf0f1'); - // Star topology info + // 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('Star Topology') + .text(modeText) .attr('x', 0) .attr('y', 50) .attr('font-size', '12px') .attr('fill', '#ecf0f1'); topologyLegend.append('text') - .text('(Primary to Members)') + .text(modeDesc) .attr('x', 0) .attr('y', 65) .attr('font-size', '10px') @@ -1288,7 +1262,9 @@ class TopologyGraphComponent extends Component { // 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'); + 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 @@ -1300,20 +1276,38 @@ class TopologyGraphComponent extends Component { } animateDiscoveryDot(animGroup, sourceNode, targetNode) { - // Create a small circle at the source node + // 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', sourceNode.x) - .attr('cy', sourceNode.y) + .attr('cx', spawnX) + .attr('cy', spawnY) .attr('fill', '#FFD700') .attr('opacity', 1) .attr('stroke', '#FFF') - .attr('stroke-width', 2); + .attr('stroke-width', 2) + .style('pointer-events', 'none'); // Make dots non-interactive - // Trigger response dot early (after 70% of the journey) + // Trigger response dot early (after 70% of the journey) - only if target node is active setTimeout(() => { - this.animateResponseDot(animGroup, targetNode, sourceNode); + if (targetNode.status && targetNode.status.toUpperCase() === 'ACTIVE') { + this.animateResponseDot(animGroup, targetNode, sourceNode); + } }, 1050); // 1500ms * 0.7 = 1050ms // Animate the dot to the target node @@ -1327,23 +1321,51 @@ class TopologyGraphComponent extends Component { } animateResponseDot(animGroup, sourceNode, targetNode) { - // Create a response dot at the target (now source) node + // 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', sourceNode.x) - .attr('cy', sourceNode.y) - .attr('fill', '#4CAF50') + .attr('cx', spawnX) + .attr('cy', spawnY) + .attr('fill', '#2196F3') .attr('opacity', 1) .attr('stroke', '#FFF') - .attr('stroke-width', 2); + .attr('stroke-width', 2) + .style('pointer-events', 'none'); // Make dots non-interactive - // Animate back to the original source + // Animate back to outside the original source node responseDot.transition() .duration(1000) .ease(d3.easeCubicInOut) - .attr('cx', targetNode.x) - .attr('cy', targetNode.y) + .attr('cx', targetX) + .attr('cy', targetY) .attr('opacity', 0) .remove(); } diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index f487282..060c127 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -584,7 +584,9 @@ class TopologyViewModel extends ViewModel { isLoading: false, error: null, lastUpdateTime: null, - selectedNode: null + selectedNode: null, + topologyMode: 'mesh', // 'mesh' or 'star' + starCenterNode: null // IP of the center node in star mode }); } @@ -692,7 +694,7 @@ class TopologyViewModel extends ViewModel { } // Build enhanced graph data with actual node connections - // Creates a star topology with the primary node at the center + // Creates either a mesh topology (all nodes connected) or star topology (center node to all others) async buildEnhancedGraphData(members, primaryNode) { const nodes = []; const links = []; @@ -730,32 +732,53 @@ class TopologyViewModel extends ViewModel { } }); - // Build links - create a star topology with primary node at center - // Only create links from the primary node to each member - // The cluster data comes from the primary, so it only knows about its direct connections - if (primaryNode) { - logger.debug(`TopologyViewModel: Creating star topology with primary ${primaryNode}`); - nodes.forEach(node => { - // Create a link from primary to each non-primary node - if (node.ip !== primaryNode) { - const member = members.find(m => m.ip === node.ip); - const latency = member?.latency || this.estimateLatency(node, { ip: primaryNode }); - - logger.debug(`TopologyViewModel: Creating link from ${primaryNode} to ${node.ip} (latency: ${latency}ms)`); + // Build links based on topology mode + const topologyMode = this.get('topologyMode') || 'mesh'; + + if (topologyMode === 'mesh') { + // Full mesh - connect all nodes to all other nodes + logger.debug('TopologyViewModel: Creating full mesh topology'); + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const sourceNode = nodes[i]; + const targetNode = nodes[j]; links.push({ - source: primaryNode, - target: node.id, - latency: latency, - sourceNode: nodes.find(n => n.ip === primaryNode), - targetNode: node, - bidirectional: false // Primary -> Member is directional + source: sourceNode.id, + target: targetNode.id, + latency: this.estimateLatency(sourceNode, targetNode), + sourceNode: sourceNode, + targetNode: targetNode, + bidirectional: true }); } - }); - logger.debug(`TopologyViewModel: Created ${links.length} links from primary node`); - } else { - logger.warn('TopologyViewModel: No primary node specified, cannot create links'); + } + logger.debug(`TopologyViewModel: Created ${links.length} links in mesh topology`); + } else if (topologyMode === 'star') { + // Star topology - center node connects to all others + const centerNode = this.get('starCenterNode') || primaryNode; + + if (centerNode) { + logger.debug(`TopologyViewModel: Creating star topology with center ${centerNode}`); + nodes.forEach(node => { + if (node.ip !== centerNode) { + const member = members.find(m => m.ip === node.ip); + const latency = member?.latency || this.estimateLatency(node, { ip: centerNode }); + + links.push({ + source: centerNode, + target: node.id, + latency: latency, + sourceNode: nodes.find(n => n.ip === centerNode), + targetNode: node, + bidirectional: false + }); + } + }); + logger.debug(`TopologyViewModel: Created ${links.length} links from center node`); + } else { + logger.warn('TopologyViewModel: No center node specified for star topology'); + } } return { nodes, links }; @@ -803,6 +826,36 @@ class TopologyViewModel extends ViewModel { throw error; } } + + // Toggle topology mode or set star mode with specific center node + async setTopologyMode(mode, centerNodeIp = null) { + logger.debug(`TopologyViewModel: Setting topology mode to ${mode}`, centerNodeIp ? `with center ${centerNodeIp}` : ''); + + this.setMultiple({ + topologyMode: mode, + starCenterNode: centerNodeIp + }); + + // Rebuild the graph with new topology + await this.updateNetworkTopology(); + } + + // Toggle between mesh and star modes + async toggleTopologyMode(nodeIp) { + const currentMode = this.get('topologyMode'); + const currentCenter = this.get('starCenterNode'); + + if (currentMode === 'mesh') { + // Switch to star mode with this node as center + await this.setTopologyMode('star', nodeIp); + } else if (currentMode === 'star' && currentCenter === nodeIp) { + // Clicking same center node - switch back to mesh + await this.setTopologyMode('mesh', null); + } else { + // Clicking different node - change star center + await this.setTopologyMode('star', nodeIp); + } + } } // Monitoring View Model for cluster resource monitoring