feat: primary node switching in topology graph
This commit is contained in:
@@ -54,6 +54,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
|
<div class="random-primary-switcher">
|
||||||
|
<button class="random-primary-toggle" id="random-primary-toggle" title="Select random primary node">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="theme-switcher">
|
<div class="theme-switcher">
|
||||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setPrimaryNode(ip) {
|
||||||
|
return this.request(`/api/discovery/primary/${encodeURIComponent(ip)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { timestamp: new Date().toISOString() }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getNodeStatus(ip) {
|
async getNodeStatus(ip) {
|
||||||
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,65 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
clusterStatusComponent.mount();
|
clusterStatusComponent.mount();
|
||||||
logger.debug('App: Cluster status component initialized');
|
logger.debug('App: Cluster status component initialized');
|
||||||
|
|
||||||
|
// Set up random primary node button
|
||||||
|
logger.debug('App: Setting up random primary node button...');
|
||||||
|
const randomPrimaryBtn = document.getElementById('random-primary-toggle');
|
||||||
|
if (randomPrimaryBtn) {
|
||||||
|
randomPrimaryBtn.addEventListener('click', async function() {
|
||||||
|
try {
|
||||||
|
// Add spinning animation
|
||||||
|
randomPrimaryBtn.classList.add('spinning');
|
||||||
|
randomPrimaryBtn.disabled = true;
|
||||||
|
|
||||||
|
logger.debug('App: Selecting random primary node...');
|
||||||
|
await clusterViewModel.selectRandomPrimaryNode();
|
||||||
|
|
||||||
|
// Show success state briefly
|
||||||
|
logger.info('App: Random primary node selected successfully');
|
||||||
|
|
||||||
|
// Refresh topology to show new primary node connections
|
||||||
|
// Wait a bit for the backend to update, then refresh topology
|
||||||
|
setTimeout(async () => {
|
||||||
|
logger.debug('App: Refreshing topology after primary node change...');
|
||||||
|
try {
|
||||||
|
await topologyViewModel.updateNetworkTopology();
|
||||||
|
logger.debug('App: Topology refreshed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('App: Failed to refresh topology:', error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Also refresh cluster view to update member list with new primary
|
||||||
|
setTimeout(async () => {
|
||||||
|
logger.debug('App: Refreshing cluster view after primary node change...');
|
||||||
|
try {
|
||||||
|
if (clusterViewModel.updateClusterMembers) {
|
||||||
|
await clusterViewModel.updateClusterMembers();
|
||||||
|
}
|
||||||
|
logger.debug('App: Cluster view refreshed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('App: Failed to refresh cluster view:', error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Remove spinning animation after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
randomPrimaryBtn.classList.remove('spinning');
|
||||||
|
randomPrimaryBtn.disabled = false;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('App: Failed to select random primary node:', error);
|
||||||
|
randomPrimaryBtn.classList.remove('spinning');
|
||||||
|
randomPrimaryBtn.disabled = false;
|
||||||
|
|
||||||
|
// Show error notification (could be enhanced with a toast notification)
|
||||||
|
alert('Failed to select random primary node: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.debug('App: Random primary node button configured');
|
||||||
|
}
|
||||||
|
|
||||||
// Set up navigation event listeners
|
// Set up navigation event listeners
|
||||||
logger.debug('App: Setting up navigation...');
|
logger.debug('App: Setting up navigation...');
|
||||||
app.setupNavigation();
|
app.setupNavigation();
|
||||||
|
|||||||
@@ -98,7 +98,16 @@ class TopologyGraphComponent extends Component {
|
|||||||
const chips = Object.entries(labels)
|
const chips = Object.entries(labels)
|
||||||
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
||||||
.join('');
|
.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.positionTooltip(pageX, pageY);
|
||||||
this.tooltipEl.classList.add('visible');
|
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', '1px solid rgba(255, 255, 255, 0.1)')
|
||||||
.style('border-radius', '12px');
|
.style('border-radius', '12px');
|
||||||
|
|
||||||
|
|
||||||
// Add zoom behavior
|
// Add zoom behavior
|
||||||
this.zoom = d3.zoom()
|
this.zoom = d3.zoom()
|
||||||
.scaleExtent([0.5, 5])
|
.scaleExtent([0.5, 5])
|
||||||
@@ -501,26 +511,32 @@ class TopologyGraphComponent extends Component {
|
|||||||
.data(links, d => {
|
.data(links, d => {
|
||||||
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
||||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
||||||
// Always return keys in consistent order (smaller IP first) for bidirectional links
|
// For directional links, maintain source -> target order in the key
|
||||||
return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`;
|
// 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
|
// Remove old links
|
||||||
link.exit()
|
link.exit()
|
||||||
.transition()
|
.transition()
|
||||||
.duration(300)
|
.duration(600)
|
||||||
|
.ease(d3.easeCubicOut)
|
||||||
.style('opacity', 0)
|
.style('opacity', 0)
|
||||||
.remove();
|
.remove();
|
||||||
|
|
||||||
// Add new links
|
// Add new links
|
||||||
const linkEnter = link.enter().append('line')
|
const linkEnter = link.enter().append('line')
|
||||||
.attr('stroke-opacity', 0)
|
.attr('stroke-opacity', 0);
|
||||||
.attr('marker-end', null);
|
|
||||||
|
|
||||||
// Merge and update all links
|
// Merge and update all links
|
||||||
const linkMerge = linkEnter.merge(link)
|
const linkMerge = linkEnter.merge(link)
|
||||||
.transition()
|
.transition()
|
||||||
.duration(300)
|
.duration(600)
|
||||||
|
.ease(d3.easeCubicInOut)
|
||||||
.attr('stroke', d => this.getLinkColor(d.latency))
|
.attr('stroke', d => this.getLinkColor(d.latency))
|
||||||
.attr('stroke-opacity', 0.7)
|
.attr('stroke-opacity', 0.7)
|
||||||
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)));
|
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)));
|
||||||
@@ -601,8 +617,8 @@ class TopologyGraphComponent extends Component {
|
|||||||
nodeEnter.append('circle')
|
nodeEnter.append('circle')
|
||||||
.attr('r', d => this.getNodeRadius(d.status))
|
.attr('r', d => this.getNodeRadius(d.status))
|
||||||
.attr('fill', d => this.getNodeColor(d.status))
|
.attr('fill', d => this.getNodeColor(d.status))
|
||||||
.attr('stroke', '#fff')
|
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
||||||
.attr('stroke-width', 2);
|
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
||||||
|
|
||||||
// Status indicator
|
// Status indicator
|
||||||
nodeEnter.append('circle')
|
nodeEnter.append('circle')
|
||||||
@@ -610,6 +626,26 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
||||||
.attr('cx', -8)
|
.attr('cx', -8)
|
||||||
.attr('cy', -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
|
// Hostname
|
||||||
nodeEnter.append('text')
|
nodeEnter.append('text')
|
||||||
@@ -659,7 +695,9 @@ class TopologyGraphComponent extends Component {
|
|||||||
.transition()
|
.transition()
|
||||||
.duration(300)
|
.duration(300)
|
||||||
.attr('r', d => this.getNodeRadius(d.status))
|
.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)')
|
nodeMerge.select('circle:nth-child(2)')
|
||||||
.transition()
|
.transition()
|
||||||
@@ -682,6 +720,37 @@ class TopologyGraphComponent extends Component {
|
|||||||
.duration(300)
|
.duration(300)
|
||||||
.attr('fill', d => this.getNodeColor(d.status));
|
.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)
|
// Fade in only new nodes (not existing ones)
|
||||||
nodeEnter.transition()
|
nodeEnter.transition()
|
||||||
.duration(300)
|
.duration(300)
|
||||||
@@ -695,7 +764,10 @@ class TopologyGraphComponent extends Component {
|
|||||||
|
|
||||||
// Add interactions
|
// Add interactions
|
||||||
this.nodeSelection
|
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.viewModel.selectNode(d.id);
|
||||||
this.updateSelection(d.id);
|
this.updateSelection(d.id);
|
||||||
if (this.isDesktop()) {
|
if (this.isDesktop()) {
|
||||||
@@ -703,6 +775,53 @@ class TopologyGraphComponent extends Component {
|
|||||||
} else {
|
} else {
|
||||||
this.showMemberCardOverlay(d);
|
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) => {
|
.on('mouseover', (event, d) => {
|
||||||
d3.select(event.currentTarget).select('circle')
|
d3.select(event.currentTarget).select('circle')
|
||||||
@@ -808,18 +927,72 @@ class TopologyGraphComponent extends Component {
|
|||||||
const currentNodeIds = new Set(currentNodes.map(n => n.id));
|
const currentNodeIds = new Set(currentNodes.map(n => n.id));
|
||||||
const newNodeIds = new Set(nodes.map(n => n.id));
|
const newNodeIds = new Set(nodes.map(n => n.id));
|
||||||
|
|
||||||
const isStructuralChange = currentNodes.length !== nodes.length ||
|
// Check if nodes changed
|
||||||
[...newNodeIds].some(id => !currentNodeIds.has(id)) ||
|
const nodesChanged = currentNodes.length !== nodes.length ||
|
||||||
[...currentNodeIds].some(id => !newNodeIds.has(id));
|
[...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
|
// Update simulation data
|
||||||
this.simulation.nodes(nodes);
|
this.simulation.nodes(nodes);
|
||||||
this.simulation.force('link').links(links);
|
this.simulation.force('link').links(links);
|
||||||
|
|
||||||
if (isStructuralChange) {
|
if (isStructuralChange) {
|
||||||
// Structural change: restart with moderate alpha
|
if (linksChanged && !nodesChanged) {
|
||||||
logger.debug('TopologyGraphComponent: Structural change detected, restarting simulation');
|
// Only links changed (e.g., primary node switch)
|
||||||
this.simulation.alpha(0.3).restart();
|
// 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 {
|
} else {
|
||||||
// Property-only change: just update data, no restart needed
|
// Property-only change: just update data, no restart needed
|
||||||
// The simulation keeps running at its current alpha
|
// The simulation keeps running at its current alpha
|
||||||
@@ -841,8 +1014,8 @@ class TopologyGraphComponent extends Component {
|
|||||||
.style('opacity', '0');
|
.style('opacity', '0');
|
||||||
|
|
||||||
legend.append('rect')
|
legend.append('rect')
|
||||||
.attr('width', 320)
|
.attr('width', 420)
|
||||||
.attr('height', 120)
|
.attr('height', 140)
|
||||||
.attr('fill', 'rgba(0, 0, 0, 0.7)')
|
.attr('fill', 'rgba(0, 0, 0, 0.7)')
|
||||||
.attr('rx', 8)
|
.attr('rx', 8)
|
||||||
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
|
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
|
||||||
@@ -913,6 +1086,57 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('font-size', '12px')
|
.attr('font-size', '12px')
|
||||||
.attr('fill', '#ecf0f1');
|
.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) {
|
getNodeRadius(status) {
|
||||||
|
|||||||
@@ -601,13 +601,14 @@ class TopologyViewModel extends ViewModel {
|
|||||||
|
|
||||||
// Update topology from WebSocket data
|
// Update topology from WebSocket data
|
||||||
if (data.members && Array.isArray(data.members)) {
|
if (data.members && Array.isArray(data.members)) {
|
||||||
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members`);
|
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members, primary: ${data.primaryNode}`);
|
||||||
|
|
||||||
// Build enhanced graph data from updated members
|
// Build enhanced graph data from updated members with primary node info
|
||||||
this.buildEnhancedGraphData(data.members).then(({ nodes, links }) => {
|
this.buildEnhancedGraphData(data.members, data.primaryNode).then(({ nodes, links }) => {
|
||||||
this.batchUpdate({
|
this.batchUpdate({
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
links: links,
|
links: links,
|
||||||
|
primaryNode: data.primaryNode,
|
||||||
lastUpdateTime: data.timestamp || new Date().toISOString()
|
lastUpdateTime: data.timestamp || new Date().toISOString()
|
||||||
});
|
});
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -658,14 +659,24 @@ class TopologyViewModel extends ViewModel {
|
|||||||
const response = await window.apiClient.getClusterMembers();
|
const response = await window.apiClient.getClusterMembers();
|
||||||
logger.debug('TopologyViewModel: Got cluster members response:', response);
|
logger.debug('TopologyViewModel: Got cluster members response:', response);
|
||||||
|
|
||||||
|
// Get discovery info to find the primary node
|
||||||
|
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||||
|
logger.debug('TopologyViewModel: Got discovery info:', discoveryInfo);
|
||||||
|
|
||||||
const members = response.members || [];
|
const members = response.members || [];
|
||||||
|
const primaryNode = discoveryInfo.primaryNode || null;
|
||||||
|
|
||||||
|
logger.debug(`TopologyViewModel: Building graph with ${members.length} members, primary: ${primaryNode}`);
|
||||||
|
|
||||||
// Build enhanced graph data with actual node connections
|
// Build enhanced graph data with actual node connections
|
||||||
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
const { nodes, links } = await this.buildEnhancedGraphData(members, primaryNode);
|
||||||
|
|
||||||
|
logger.debug(`TopologyViewModel: Built graph with ${nodes.length} nodes and ${links.length} links`);
|
||||||
|
|
||||||
this.batchUpdate({
|
this.batchUpdate({
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
links: links,
|
links: links,
|
||||||
|
primaryNode: primaryNode,
|
||||||
lastUpdateTime: new Date().toISOString()
|
lastUpdateTime: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -679,7 +690,8 @@ class TopologyViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build enhanced graph data with actual node connections
|
// Build enhanced graph data with actual node connections
|
||||||
async buildEnhancedGraphData(members) {
|
// Creates a star topology with the primary node at the center
|
||||||
|
async buildEnhancedGraphData(members, primaryNode) {
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
const links = [];
|
const links = [];
|
||||||
|
|
||||||
@@ -691,6 +703,7 @@ class TopologyViewModel extends ViewModel {
|
|||||||
members.forEach((member, index) => {
|
members.forEach((member, index) => {
|
||||||
if (member && member.ip) {
|
if (member && member.ip) {
|
||||||
const existingNode = existingNodeMap.get(member.ip);
|
const existingNode = existingNodeMap.get(member.ip);
|
||||||
|
const isPrimary = member.ip === primaryNode;
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: member.ip,
|
id: member.ip,
|
||||||
@@ -698,6 +711,7 @@ class TopologyViewModel extends ViewModel {
|
|||||||
ip: member.ip,
|
ip: member.ip,
|
||||||
status: member.status || 'UNKNOWN',
|
status: member.status || 'UNKNOWN',
|
||||||
latency: member.latency || 0,
|
latency: member.latency || 0,
|
||||||
|
isPrimary: isPrimary,
|
||||||
// Preserve both legacy 'resources' and preferred 'labels'
|
// Preserve both legacy 'resources' and preferred 'labels'
|
||||||
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
||||||
resources: member.resources || {},
|
resources: member.resources || {},
|
||||||
@@ -714,28 +728,32 @@ class TopologyViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build links - create a mesh topology from the members data
|
// Build links - create a star topology with primary node at center
|
||||||
// All connections are inferred from the cluster membership
|
// Only create links from the primary node to each member
|
||||||
// No additional API calls needed - all data comes from websocket updates
|
// The cluster data comes from the primary, so it only knows about its direct connections
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
if (primaryNode) {
|
||||||
for (let j = i + 1; j < nodes.length; j++) {
|
logger.debug(`TopologyViewModel: Creating star topology with primary ${primaryNode}`);
|
||||||
const sourceNode = nodes[i];
|
nodes.forEach(node => {
|
||||||
const targetNode = nodes[j];
|
// Create a link from primary to each non-primary node
|
||||||
|
if (node.ip !== primaryNode) {
|
||||||
// Use the latency from the member data if available, otherwise estimate
|
const member = members.find(m => m.ip === node.ip);
|
||||||
const sourceMember = members.find(m => m.ip === sourceNode.ip);
|
const latency = member?.latency || this.estimateLatency(node, { ip: primaryNode });
|
||||||
const targetMember = members.find(m => m.ip === targetNode.ip);
|
|
||||||
const latency = targetMember?.latency || sourceMember?.latency || this.estimateLatency(sourceNode, targetNode);
|
logger.debug(`TopologyViewModel: Creating link from ${primaryNode} to ${node.ip} (latency: ${latency}ms)`);
|
||||||
|
|
||||||
links.push({
|
links.push({
|
||||||
source: sourceNode.id,
|
source: primaryNode,
|
||||||
target: targetNode.id,
|
target: node.id,
|
||||||
latency: latency,
|
latency: latency,
|
||||||
sourceNode: sourceNode,
|
sourceNode: nodes.find(n => n.ip === primaryNode),
|
||||||
targetNode: targetNode,
|
targetNode: node,
|
||||||
bidirectional: true
|
bidirectional: false // Primary -> Member is directional
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
logger.debug(`TopologyViewModel: Created ${links.length} links from primary node`);
|
||||||
|
} else {
|
||||||
|
logger.warn('TopologyViewModel: No primary node specified, cannot create links');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, links };
|
return { nodes, links };
|
||||||
@@ -758,6 +776,31 @@ class TopologyViewModel extends ViewModel {
|
|||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.set('selectedNode', null);
|
this.set('selectedNode', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Switch to a specific node as the new primary
|
||||||
|
async switchToPrimaryNode(nodeIp) {
|
||||||
|
try {
|
||||||
|
logger.debug(`TopologyViewModel: Switching primary node to ${nodeIp}`);
|
||||||
|
const result = await window.apiClient.setPrimaryNode(nodeIp);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`TopologyViewModel: Successfully switched primary to ${nodeIp}`);
|
||||||
|
|
||||||
|
// Update topology after a short delay to allow backend to update
|
||||||
|
setTimeout(async () => {
|
||||||
|
logger.debug('TopologyViewModel: Refreshing topology after primary switch...');
|
||||||
|
await this.updateNetworkTopology();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Failed to switch primary node');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('TopologyViewModel: Failed to switch primary node:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitoring View Model for cluster resource monitoring
|
// Monitoring View Model for cluster resource monitoring
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ button:disabled {
|
|||||||
/* === Icon-Only Button Style (Minimal) === */
|
/* === Icon-Only Button Style (Minimal) === */
|
||||||
.btn-icon,
|
.btn-icon,
|
||||||
.theme-toggle,
|
.theme-toggle,
|
||||||
|
.random-primary-toggle,
|
||||||
.burger-btn,
|
.burger-btn,
|
||||||
.primary-node-refresh,
|
.primary-node-refresh,
|
||||||
.filter-pill-remove,
|
.filter-pill-remove,
|
||||||
@@ -96,6 +97,7 @@ button:disabled {
|
|||||||
|
|
||||||
.btn-icon:hover,
|
.btn-icon:hover,
|
||||||
.theme-toggle:hover,
|
.theme-toggle:hover,
|
||||||
|
.random-primary-toggle:hover,
|
||||||
.burger-btn:hover,
|
.burger-btn:hover,
|
||||||
.primary-node-refresh:hover,
|
.primary-node-refresh:hover,
|
||||||
.filter-pill-remove:hover,
|
.filter-pill-remove:hover,
|
||||||
@@ -527,6 +529,44 @@ p {
|
|||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.random-primary-toggle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-primary-toggle svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-primary-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-primary-toggle:hover svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-primary-toggle.spinning svg {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-primary-toggle:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topology graph node interactions */
|
||||||
|
#topology-graph-container .node {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topology-graph-container .node:hover circle:first-child {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
|||||||
Reference in New Issue
Block a user