feat: primary node switching in topology graph
This commit is contained in:
@@ -98,7 +98,16 @@ class TopologyGraphComponent extends Component {
|
||||
const chips = Object.entries(labels)
|
||||
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
||||
.join('');
|
||||
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>`;
|
||||
|
||||
// Add hint for non-primary nodes
|
||||
let hint = '';
|
||||
if (nodeData && !nodeData.isPrimary) {
|
||||
hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 255, 255, 0.7); text-align: center;">💡 Click to switch to primary & view details</div>';
|
||||
} else if (nodeData && nodeData.isPrimary) {
|
||||
hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 215, 0, 0.9); text-align: center;">⭐ Primary Node - Click to view details</div>';
|
||||
}
|
||||
|
||||
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>${hint}`;
|
||||
this.positionTooltip(pageX, pageY);
|
||||
this.tooltipEl.classList.add('visible');
|
||||
}
|
||||
@@ -310,6 +319,7 @@ class TopologyGraphComponent extends Component {
|
||||
.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])
|
||||
@@ -501,26 +511,32 @@ class TopologyGraphComponent extends Component {
|
||||
.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;
|
||||
// Always return keys in consistent order (smaller IP first) for bidirectional links
|
||||
return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`;
|
||||
// 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(300)
|
||||
.duration(600)
|
||||
.ease(d3.easeCubicOut)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
|
||||
// Add new links
|
||||
const linkEnter = link.enter().append('line')
|
||||
.attr('stroke-opacity', 0)
|
||||
.attr('marker-end', null);
|
||||
.attr('stroke-opacity', 0);
|
||||
|
||||
// Merge and update all links
|
||||
const linkMerge = linkEnter.merge(link)
|
||||
.transition()
|
||||
.duration(300)
|
||||
.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)));
|
||||
@@ -601,8 +617,8 @@ class TopologyGraphComponent extends Component {
|
||||
nodeEnter.append('circle')
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2);
|
||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
||||
|
||||
// Status indicator
|
||||
nodeEnter.append('circle')
|
||||
@@ -610,6 +626,26 @@ class TopologyGraphComponent extends Component {
|
||||
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
||||
.attr('cx', -8)
|
||||
.attr('cy', -8);
|
||||
|
||||
// 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);
|
||||
|
||||
primaryBadge.append('text')
|
||||
.text('P')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('font-size', '10px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#000');
|
||||
|
||||
// Hostname
|
||||
nodeEnter.append('text')
|
||||
@@ -659,7 +695,9 @@ class TopologyGraphComponent extends Component {
|
||||
.transition()
|
||||
.duration(300)
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('fill', d => this.getNodeColor(d.status));
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
||||
|
||||
nodeMerge.select('circle:nth-child(2)')
|
||||
.transition()
|
||||
@@ -682,6 +720,37 @@ class TopologyGraphComponent extends Component {
|
||||
.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(8, -8)');
|
||||
|
||||
badge.append('circle')
|
||||
.attr('r', 8)
|
||||
.attr('fill', '#FFD700')
|
||||
.attr('stroke', '#FFF')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
badge.append('text')
|
||||
.text('P')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('font-size', '10px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#000');
|
||||
}
|
||||
});
|
||||
|
||||
// Fade in only new nodes (not existing ones)
|
||||
nodeEnter.transition()
|
||||
.duration(300)
|
||||
@@ -695,7 +764,10 @@ class TopologyGraphComponent extends Component {
|
||||
|
||||
// Add interactions
|
||||
this.nodeSelection
|
||||
.on('click', (event, d) => {
|
||||
.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()) {
|
||||
@@ -703,6 +775,53 @@ class TopologyGraphComponent extends Component {
|
||||
} else {
|
||||
this.showMemberCardOverlay(d);
|
||||
}
|
||||
|
||||
// If clicking on non-primary node, also switch it to primary
|
||||
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');
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('mouseover', (event, d) => {
|
||||
d3.select(event.currentTarget).select('circle')
|
||||
@@ -808,18 +927,72 @@ class TopologyGraphComponent extends Component {
|
||||
const currentNodeIds = new Set(currentNodes.map(n => n.id));
|
||||
const newNodeIds = new Set(nodes.map(n => n.id));
|
||||
|
||||
const isStructuralChange = currentNodes.length !== nodes.length ||
|
||||
[...newNodeIds].some(id => !currentNodeIds.has(id)) ||
|
||||
[...currentNodeIds].some(id => !newNodeIds.has(id));
|
||||
// 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) {
|
||||
// Structural change: restart with moderate alpha
|
||||
logger.debug('TopologyGraphComponent: Structural change detected, restarting simulation');
|
||||
this.simulation.alpha(0.3).restart();
|
||||
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
|
||||
@@ -841,8 +1014,8 @@ class TopologyGraphComponent extends Component {
|
||||
.style('opacity', '0');
|
||||
|
||||
legend.append('rect')
|
||||
.attr('width', 320)
|
||||
.attr('height', 120)
|
||||
.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)')
|
||||
@@ -913,6 +1086,57 @@ class TopologyGraphComponent extends Component {
|
||||
.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');
|
||||
|
||||
// Star topology info
|
||||
topologyLegend.append('text')
|
||||
.text('Star Topology')
|
||||
.attr('x', 0)
|
||||
.attr('y', 50)
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', '#ecf0f1');
|
||||
|
||||
topologyLegend.append('text')
|
||||
.text('(Primary to Members)')
|
||||
.attr('x', 0)
|
||||
.attr('y', 65)
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', 'rgba(236, 240, 241, 0.7)');
|
||||
}
|
||||
|
||||
getNodeRadius(status) {
|
||||
|
||||
Reference in New Issue
Block a user