diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js
index bec4aa4..2e75bdc 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 API Node & toggle view
';
+ if (nodeData && nodeData.isPrimary) {
+ hint = '⭐ API 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')
+ .text('A')
.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')
+ .text('A')
.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')
@@ -1111,7 +1081,7 @@ class TopologyGraphComponent extends Component {
.attr('stroke-width', 1);
primaryBadge.append('text')
- .text('P')
+ .text('A')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-size', '8px')
@@ -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