feat: live topology view through websocket updates
This commit is contained in:
@@ -15,6 +15,11 @@ class TopologyGraphComponent extends Component {
|
||||
|
||||
// Tooltip for labels on hover
|
||||
this.tooltipEl = null;
|
||||
|
||||
// Track drag state to defer updates
|
||||
this.isDragging = false;
|
||||
this.pendingUpdate = null;
|
||||
this.draggedNodePositions = new Map(); // Track final positions of dragged nodes
|
||||
}
|
||||
|
||||
// Determine desktop threshold
|
||||
@@ -253,10 +258,26 @@ class TopologyGraphComponent extends Component {
|
||||
this._pendingSubscriptions = null;
|
||||
}
|
||||
|
||||
// Set up WebSocket listeners for real-time updates
|
||||
this.setupWebSocketListeners();
|
||||
|
||||
// Initial data load
|
||||
await this.viewModel.updateNetworkTopology();
|
||||
}
|
||||
|
||||
setupWebSocketListeners() {
|
||||
logger.debug('TopologyGraphComponent: Setting up WebSocket listeners...');
|
||||
|
||||
// The view model handles WebSocket events and updates its state
|
||||
// The component will automatically re-render via view model subscriptions
|
||||
if (this.viewModel && typeof this.viewModel.setupWebSocketListeners === 'function') {
|
||||
this.viewModel.setupWebSocketListeners();
|
||||
logger.debug('TopologyGraphComponent: WebSocket listeners configured');
|
||||
} else {
|
||||
logger.warn('TopologyGraphComponent: View model does not support WebSocket listeners');
|
||||
}
|
||||
}
|
||||
|
||||
setupSVG() {
|
||||
const container = this.findElement('#topology-graph-container');
|
||||
if (!container) {
|
||||
@@ -270,14 +291,23 @@ class TopologyGraphComponent extends Component {
|
||||
// Clear existing content
|
||||
container.innerHTML = '';
|
||||
|
||||
// Create a wrapper for SVG and controls
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.position = 'relative';
|
||||
wrapper.style.width = '100%';
|
||||
wrapper.style.height = '100%';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Add rearrange button
|
||||
this.createRearrangeButton(wrapper);
|
||||
|
||||
// Create SVG element
|
||||
this.svg = d3.select(container)
|
||||
this.svg = d3.select(wrapper)
|
||||
.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
|
||||
@@ -298,6 +328,94 @@ class TopologyGraphComponent extends Component {
|
||||
logger.debug('TopologyGraphComponent: SVG setup completed');
|
||||
}
|
||||
|
||||
createRearrangeButton(container) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'topology-rearrange-btn';
|
||||
button.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="5" r="1.5"/>
|
||||
<circle cx="12" cy="12" r="1.5"/>
|
||||
<circle cx="12" cy="19" r="1.5"/>
|
||||
<circle cx="5" cy="12" r="1.5"/>
|
||||
<circle cx="19" cy="12" r="1.5"/>
|
||||
<path d="M12 7v3m0 2v3m-5-3h3m2 0h3"/>
|
||||
</svg>
|
||||
<span>Rearrange</span>
|
||||
`;
|
||||
button.title = 'Rearrange nodes into a clean layout';
|
||||
button.style.cssText = `
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--card-background, rgba(30, 30, 30, 0.95));
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary, #ecf0f1);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
`;
|
||||
|
||||
// Add hover effect
|
||||
button.addEventListener('mouseenter', () => {
|
||||
button.style.background = 'var(--card-hover, rgba(40, 40, 40, 0.95))';
|
||||
button.style.borderColor = 'var(--primary-color, #3498db)';
|
||||
button.style.transform = 'translateY(-1px)';
|
||||
button.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)';
|
||||
});
|
||||
|
||||
button.addEventListener('mouseleave', () => {
|
||||
button.style.background = 'var(--card-background, rgba(30, 30, 30, 0.95))';
|
||||
button.style.borderColor = 'var(--border-color, rgba(255, 255, 255, 0.1))';
|
||||
button.style.transform = 'translateY(0)';
|
||||
button.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)';
|
||||
});
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
this.rearrangeNodes();
|
||||
});
|
||||
|
||||
container.appendChild(button);
|
||||
}
|
||||
|
||||
rearrangeNodes() {
|
||||
logger.debug('TopologyGraphComponent: Rearranging nodes');
|
||||
|
||||
// Clear all manually dragged positions
|
||||
this.draggedNodePositions.clear();
|
||||
logger.debug('TopologyGraphComponent: Cleared dragged positions');
|
||||
|
||||
if (!this.simulation) {
|
||||
logger.warn('TopologyGraphComponent: No simulation to rearrange');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current nodes and reset their fixed positions
|
||||
const nodes = this.simulation.nodes();
|
||||
nodes.forEach(node => {
|
||||
node.fx = null;
|
||||
node.fy = null;
|
||||
// Give them a slight random velocity to help spread out
|
||||
node.vx = (Math.random() - 0.5) * 50;
|
||||
node.vy = (Math.random() - 0.5) * 50;
|
||||
});
|
||||
|
||||
// Restart the simulation with high alpha for a fresh layout
|
||||
this.simulation
|
||||
.alpha(1)
|
||||
.alphaTarget(0)
|
||||
.restart();
|
||||
|
||||
logger.debug('TopologyGraphComponent: Simulation restarted for rearrangement');
|
||||
}
|
||||
|
||||
// Ensure component is initialized
|
||||
async ensureInitialized() {
|
||||
if (!this.isInitialized) {
|
||||
@@ -324,6 +442,16 @@ class TopologyGraphComponent extends Component {
|
||||
const nodes = this.viewModel.get('nodes');
|
||||
const links = this.viewModel.get('links');
|
||||
|
||||
// Defer updates while dragging
|
||||
if (this.isDragging) {
|
||||
logger.debug('TopologyGraphComponent: Drag in progress, deferring update');
|
||||
this.pendingUpdate = { nodes, links };
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending update since we're processing now
|
||||
this.pendingUpdate = null;
|
||||
|
||||
// Check if SVG is initialized
|
||||
if (!this.svg) {
|
||||
logger.debug('TopologyGraphComponent: SVG not initialized yet, setting up SVG first');
|
||||
@@ -346,162 +474,13 @@ class TopologyGraphComponent extends Component {
|
||||
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);
|
||||
});
|
||||
// Use D3's enter/exit pattern for smooth dynamic updates
|
||||
this.updateLinks(svgGroup, links);
|
||||
this.updateNodes(svgGroup, nodes);
|
||||
this.updateLinkLabels(svgGroup, links);
|
||||
|
||||
// Update or create simulation
|
||||
this.updateSimulation(nodes, links, svgGroup);
|
||||
|
||||
this.addLegend(svgGroup);
|
||||
} catch (error) {
|
||||
@@ -509,9 +488,345 @@ class TopologyGraphComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
updateLinks(svgGroup, links) {
|
||||
// Get or create link group
|
||||
let linkGroup = svgGroup.select('.link-group');
|
||||
if (linkGroup.empty()) {
|
||||
linkGroup = svgGroup.append('g').attr('class', 'link-group graph-element');
|
||||
}
|
||||
|
||||
// Bind data with key function for proper enter/exit
|
||||
const link = linkGroup.selectAll('line')
|
||||
.data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
|
||||
|
||||
// Remove old links
|
||||
link.exit()
|
||||
.transition()
|
||||
.duration(300)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
|
||||
// Add new links
|
||||
const linkEnter = link.enter().append('line')
|
||||
.attr('stroke-opacity', 0)
|
||||
.attr('marker-end', null);
|
||||
|
||||
// Merge and update all links
|
||||
const linkMerge = linkEnter.merge(link)
|
||||
.transition()
|
||||
.duration(300)
|
||||
.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)));
|
||||
|
||||
// Store reference for simulation
|
||||
this.linkSelection = linkGroup.selectAll('line');
|
||||
|
||||
// Add interactions to links
|
||||
this.linkSelection
|
||||
.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);
|
||||
})
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
updateNodes(svgGroup, nodes) {
|
||||
// Get or create node group
|
||||
let nodeGroup = svgGroup.select('.node-group');
|
||||
if (nodeGroup.empty()) {
|
||||
nodeGroup = svgGroup.append('g').attr('class', 'node-group graph-element');
|
||||
}
|
||||
|
||||
// Merge live simulation positions with new data
|
||||
if (this.simulation) {
|
||||
const simulationNodes = this.simulation.nodes();
|
||||
const simNodeMap = new Map(simulationNodes.map(n => [n.id, n]));
|
||||
|
||||
nodes.forEach(node => {
|
||||
const simNode = simNodeMap.get(node.id);
|
||||
if (simNode) {
|
||||
// Keep simulation's position data (source of truth)
|
||||
node.x = simNode.x;
|
||||
node.y = simNode.y;
|
||||
node.vx = simNode.vx;
|
||||
node.vy = simNode.vy;
|
||||
node.fx = simNode.fx;
|
||||
node.fy = simNode.fy;
|
||||
}
|
||||
|
||||
// Apply saved dragged positions (always pin these)
|
||||
const draggedPos = this.draggedNodePositions.get(node.id);
|
||||
if (draggedPos) {
|
||||
node.x = draggedPos.x;
|
||||
node.y = draggedPos.y;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bind data with key function
|
||||
const node = nodeGroup.selectAll('g.node')
|
||||
.data(nodes, d => d.id);
|
||||
|
||||
// Remove old nodes and clean up their dragged positions
|
||||
node.exit()
|
||||
.each((d) => {
|
||||
// Clean up dragged position data for removed nodes
|
||||
this.draggedNodePositions.delete(d.id);
|
||||
logger.debug(`TopologyGraphComponent: Cleaned up dragged position for removed node ${d.id}`);
|
||||
})
|
||||
.transition()
|
||||
.duration(300)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
|
||||
// Add new nodes
|
||||
const nodeEnter = node.enter().append('g')
|
||||
.attr('class', 'node')
|
||||
.style('opacity', 0)
|
||||
.call(this.drag(this.simulation));
|
||||
|
||||
// Add circles to new nodes
|
||||
nodeEnter.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
|
||||
nodeEnter.append('circle')
|
||||
.attr('r', 3)
|
||||
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
||||
.attr('cx', -8)
|
||||
.attr('cy', -8);
|
||||
|
||||
// Hostname
|
||||
nodeEnter.append('text')
|
||||
.attr('class', 'hostname-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
|
||||
nodeEnter.append('text')
|
||||
.attr('class', 'ip-text')
|
||||
.text(d => d.ip)
|
||||
.attr('x', 15)
|
||||
.attr('y', 22)
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', 'var(--text-secondary)');
|
||||
|
||||
// App label
|
||||
nodeEnter.append('text')
|
||||
.attr('class', 'app-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
|
||||
nodeEnter.append('text')
|
||||
.attr('class', 'status-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');
|
||||
|
||||
// Merge and update all nodes
|
||||
const nodeMerge = nodeEnter.merge(node);
|
||||
|
||||
// Update existing node properties with transition
|
||||
nodeMerge.select('circle:first-child')
|
||||
.transition()
|
||||
.duration(300)
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('fill', d => this.getNodeColor(d.status));
|
||||
|
||||
nodeMerge.select('circle:nth-child(2)')
|
||||
.transition()
|
||||
.duration(300)
|
||||
.attr('fill', d => this.getStatusIndicatorColor(d.status));
|
||||
|
||||
nodeMerge.select('.hostname-text')
|
||||
.text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname);
|
||||
|
||||
nodeMerge.select('.ip-text')
|
||||
.text(d => d.ip);
|
||||
|
||||
nodeMerge.select('.app-text')
|
||||
.text(d => (d.labels && d.labels.app) ? String(d.labels.app) : '')
|
||||
.attr('display', d => (d.labels && d.labels.app) ? null : 'none');
|
||||
|
||||
nodeMerge.select('.status-text')
|
||||
.text(d => d.status)
|
||||
.transition()
|
||||
.duration(300)
|
||||
.attr('fill', d => this.getNodeColor(d.status));
|
||||
|
||||
// Fade in only new nodes (not existing ones)
|
||||
nodeEnter.transition()
|
||||
.duration(300)
|
||||
.style('opacity', 1);
|
||||
|
||||
// Ensure existing nodes remain visible
|
||||
node.style('opacity', 1);
|
||||
|
||||
// Store reference for simulation
|
||||
this.nodeSelection = nodeMerge;
|
||||
|
||||
// Add interactions
|
||||
this.nodeSelection
|
||||
.on('click', (event, d) => {
|
||||
this.viewModel.selectNode(d.id);
|
||||
this.updateSelection(d.id);
|
||||
if (this.isDesktop()) {
|
||||
this.openDrawerForNode(d);
|
||||
} else {
|
||||
this.showMemberCardOverlay(d);
|
||||
}
|
||||
})
|
||||
.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);
|
||||
})
|
||||
.on('mouseout', (event, d) => {
|
||||
d3.select(event.currentTarget).select('circle')
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('stroke-width', 2);
|
||||
this.hideTooltip();
|
||||
})
|
||||
.on('mousemove', (event, d) => {
|
||||
this.moveTooltip(event.pageX, event.pageY);
|
||||
});
|
||||
}
|
||||
|
||||
updateLinkLabels(svgGroup, links) {
|
||||
// Get or create link label group
|
||||
let linkLabelGroup = svgGroup.select('.link-label-group');
|
||||
if (linkLabelGroup.empty()) {
|
||||
linkLabelGroup = svgGroup.append('g').attr('class', 'link-label-group graph-element');
|
||||
}
|
||||
|
||||
// Bind data
|
||||
const linkLabels = linkLabelGroup.selectAll('text')
|
||||
.data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
|
||||
|
||||
// Remove old labels
|
||||
linkLabels.exit()
|
||||
.transition()
|
||||
.duration(300)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
|
||||
// Add new labels
|
||||
const linkLabelsEnter = linkLabels.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)')
|
||||
.style('opacity', 0);
|
||||
|
||||
// Merge and update
|
||||
linkLabelsEnter.merge(linkLabels)
|
||||
.text(d => `${d.latency}ms`)
|
||||
.transition()
|
||||
.duration(300)
|
||||
.style('opacity', 1);
|
||||
|
||||
// Store reference for simulation
|
||||
this.linkLabelSelection = linkLabelGroup.selectAll('text');
|
||||
}
|
||||
|
||||
updateSimulation(nodes, links, svgGroup) {
|
||||
if (!this.simulation) {
|
||||
// Create new 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));
|
||||
|
||||
// Set up tick handler
|
||||
this.simulation.on('tick', () => {
|
||||
// Update link positions
|
||||
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);
|
||||
}
|
||||
|
||||
// Update link label positions
|
||||
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);
|
||||
}
|
||||
|
||||
// Update node positions
|
||||
if (this.nodeSelection) {
|
||||
this.nodeSelection
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Don't update simulation if user is dragging
|
||||
if (this.isDragging) {
|
||||
logger.debug('TopologyGraphComponent: Skipping simulation update during drag');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a structural change (nodes/links added/removed)
|
||||
const currentNodes = this.simulation.nodes();
|
||||
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));
|
||||
|
||||
// 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();
|
||||
} else {
|
||||
// Property-only change: just update data, no restart needed
|
||||
// The simulation keeps running at its current alpha
|
||||
logger.debug('TopologyGraphComponent: Property-only update, continuing simulation');
|
||||
// Don't call restart() - let it continue naturally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addLegend(svgGroup) {
|
||||
// Only add legend if it doesn't exist
|
||||
if (!svgGroup.select('.legend-group').empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legend = svgGroup.append('g')
|
||||
.attr('class', 'graph-element')
|
||||
.attr('class', 'legend-group graph-element')
|
||||
.attr('transform', `translate(120, 120)`) // Hidden by CSS opacity
|
||||
.style('opacity', '0');
|
||||
|
||||
@@ -617,8 +932,8 @@ class TopologyGraphComponent extends Component {
|
||||
}
|
||||
|
||||
getLinkColor(latency) {
|
||||
if (latency <= 30) return '#10b981';
|
||||
if (latency <= 50) return '#f59e0b';
|
||||
if (latency <= 50) return '#10b981';
|
||||
if (latency <= 100) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
@@ -637,26 +952,68 @@ class TopologyGraphComponent extends Component {
|
||||
|
||||
drag(simulation) {
|
||||
return d3.drag()
|
||||
.on('start', function(event, d) {
|
||||
.on('start', (event, d) => {
|
||||
// Set dragging flag to defer updates
|
||||
this.isDragging = true;
|
||||
logger.debug('TopologyGraphComponent: Drag started, updates deferred');
|
||||
|
||||
if (!event.active && simulation && simulation.alphaTarget) {
|
||||
simulation.alphaTarget(0.3).restart();
|
||||
}
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on('drag', function(event, d) {
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on('end', function(event, d) {
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active && simulation && simulation.alphaTarget) {
|
||||
simulation.alphaTarget(0);
|
||||
}
|
||||
|
||||
// Save the final position before releasing
|
||||
const finalX = d.fx;
|
||||
const finalY = d.fy;
|
||||
|
||||
// Store the dragged position to preserve it across updates
|
||||
this.draggedNodePositions.set(d.id, { x: finalX, y: finalY });
|
||||
logger.debug(`TopologyGraphComponent: Saved dragged position for ${d.id}: (${finalX}, ${finalY})`);
|
||||
|
||||
// Update the node data in view model with the new position
|
||||
this.updateNodePositionInViewModel(d.id, finalX, finalY);
|
||||
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
|
||||
// Clear dragging flag
|
||||
this.isDragging = false;
|
||||
logger.debug('TopologyGraphComponent: Drag ended');
|
||||
|
||||
// Process any pending updates
|
||||
if (this.pendingUpdate) {
|
||||
logger.debug('TopologyGraphComponent: Processing deferred update after drag');
|
||||
// Use setTimeout to ensure drag event completes first
|
||||
setTimeout(() => {
|
||||
this.renderGraph();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateNodePositionInViewModel(nodeId, x, y) {
|
||||
// Update the node position in the view model to persist the drag
|
||||
const nodes = this.viewModel.get('nodes');
|
||||
if (nodes) {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (node) {
|
||||
node.x = x;
|
||||
node.y = y;
|
||||
logger.debug(`TopologyGraphComponent: Updated node ${nodeId} position in view model`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSelection(selectedNodeId) {
|
||||
// Update visual selection
|
||||
if (!this.svg || !this.isInitialized) {
|
||||
@@ -691,7 +1048,14 @@ class TopologyGraphComponent extends Component {
|
||||
const container = this.findElement('#topology-graph-container');
|
||||
|
||||
if (isLoading) {
|
||||
container.innerHTML = '<div class="loading"><div>Loading network topology...</div></div>';
|
||||
// Only show loading state if there's no SVG already rendered
|
||||
// This prevents clearing the graph during updates
|
||||
const hasSVG = container.querySelector('svg');
|
||||
if (!hasSVG) {
|
||||
container.innerHTML = '<div class="loading"><div>Loading network topology...</div></div>';
|
||||
} else {
|
||||
logger.debug('TopologyGraphComponent: SVG exists, skipping loading state to preserve graph');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,7 +1069,15 @@ class TopologyGraphComponent extends Component {
|
||||
|
||||
showNoData() {
|
||||
const container = this.findElement('#topology-graph-container');
|
||||
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
|
||||
|
||||
// Only show no-data state if there's no SVG already rendered
|
||||
// This prevents clearing the graph during transient states
|
||||
const hasSVG = container.querySelector('svg');
|
||||
if (!hasSVG) {
|
||||
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
|
||||
} else {
|
||||
logger.debug('TopologyGraphComponent: SVG exists, keeping existing graph visible');
|
||||
}
|
||||
}
|
||||
|
||||
showMemberCardOverlay(nodeData) {
|
||||
@@ -763,12 +1135,17 @@ class TopologyGraphComponent extends Component {
|
||||
}
|
||||
const nodes = this.viewModel.get('nodes');
|
||||
const links = this.viewModel.get('links');
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
|
||||
if (nodes && nodes.length > 0) {
|
||||
logger.debug('TopologyGraphComponent: Rendering graph with data');
|
||||
this.renderGraph();
|
||||
} else {
|
||||
logger.debug('TopologyGraphComponent: No data available, showing loading state');
|
||||
} else if (isLoading) {
|
||||
logger.debug('TopologyGraphComponent: Loading, showing loading state');
|
||||
this.handleLoadingState(true);
|
||||
} else {
|
||||
logger.debug('TopologyGraphComponent: No data available, showing no data state');
|
||||
this.showNoData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +1162,11 @@ class TopologyGraphComponent extends Component {
|
||||
this.resizeTimeout = null;
|
||||
}
|
||||
|
||||
// Clear dragged node positions
|
||||
if (this.draggedNodePositions) {
|
||||
this.draggedNodePositions.clear();
|
||||
}
|
||||
|
||||
// Call parent unmount
|
||||
super.unmount();
|
||||
}
|
||||
|
||||
@@ -588,6 +588,67 @@ class TopologyViewModel extends ViewModel {
|
||||
});
|
||||
}
|
||||
|
||||
// Set up WebSocket event listeners for real-time topology updates
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) {
|
||||
logger.warn('TopologyViewModel: WebSocket client not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for cluster updates
|
||||
window.wsClient.on('clusterUpdate', (data) => {
|
||||
logger.debug('TopologyViewModel: Received WebSocket cluster update:', data);
|
||||
|
||||
// Update topology from WebSocket data
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members`);
|
||||
|
||||
// Build enhanced graph data from updated members
|
||||
this.buildEnhancedGraphData(data.members).then(({ nodes, links }) => {
|
||||
this.batchUpdate({
|
||||
nodes: nodes,
|
||||
links: links,
|
||||
lastUpdateTime: data.timestamp || new Date().toISOString()
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error('TopologyViewModel: Failed to build graph data from websocket update:', error);
|
||||
});
|
||||
} else {
|
||||
logger.warn('TopologyViewModel: Received cluster update but no valid members array:', data);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for node discovery events
|
||||
window.wsClient.on('nodeDiscovery', (data) => {
|
||||
logger.debug('TopologyViewModel: Received WebSocket node discovery event:', data);
|
||||
|
||||
if (data.action === 'discovered') {
|
||||
// A new node was discovered - trigger a topology update
|
||||
setTimeout(() => {
|
||||
this.updateNetworkTopology();
|
||||
}, 500);
|
||||
} else if (data.action === 'stale') {
|
||||
// A node became stale - trigger a topology update
|
||||
setTimeout(() => {
|
||||
this.updateNetworkTopology();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for connection status changes
|
||||
window.wsClient.on('connected', () => {
|
||||
logger.debug('TopologyViewModel: WebSocket connected');
|
||||
// Trigger an immediate update when connection is restored
|
||||
setTimeout(() => {
|
||||
this.updateNetworkTopology();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
logger.debug('TopologyViewModel: WebSocket disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
// Update network topology data
|
||||
async updateNetworkTopology() {
|
||||
try {
|
||||
@@ -626,9 +687,15 @@ class TopologyViewModel extends ViewModel {
|
||||
const links = [];
|
||||
const nodeConnections = new Map();
|
||||
|
||||
// Get existing nodes to preserve their positions
|
||||
const existingNodes = this.get('nodes') || [];
|
||||
const existingNodeMap = new Map(existingNodes.map(n => [n.id, n]));
|
||||
|
||||
// Create nodes from members
|
||||
members.forEach((member, index) => {
|
||||
if (member && member.ip) {
|
||||
const existingNode = existingNodeMap.get(member.ip);
|
||||
|
||||
nodes.push({
|
||||
id: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
@@ -638,8 +705,15 @@ class TopologyViewModel extends ViewModel {
|
||||
// Preserve both legacy 'resources' and preferred 'labels'
|
||||
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
||||
resources: member.resources || {},
|
||||
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
||||
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
||||
// Preserve existing position if node already exists, otherwise assign random position
|
||||
x: existingNode ? existingNode.x : Math.random() * 1200 + 100,
|
||||
y: existingNode ? existingNode.y : Math.random() * 800 + 100,
|
||||
// Preserve velocity if it exists (for D3 simulation)
|
||||
vx: existingNode ? existingNode.vx : undefined,
|
||||
vy: existingNode ? existingNode.vy : undefined,
|
||||
// Preserve fixed position if it was dragged
|
||||
fx: existingNode ? existingNode.fx : undefined,
|
||||
fy: existingNode ? existingNode.fy : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user