Files
spore-ui/public/scripts/components/TopologyGraphComponent.js

1316 lines
49 KiB
JavaScript

// Topology Graph Component with D3.js force-directed visualization
class TopologyGraphComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('TopologyGraphComponent: Constructor called');
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 0; // Will be set dynamically based on container size
this.height = 0; // Will be set dynamically based on container size
this.isInitialized = false;
// Drawer state for desktop reuse (shared singleton)
this.drawer = new DrawerComponent();
// 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
isDesktop() {
return this.drawer.isDesktop();
}
openDrawerForNode(nodeData) {
// Get display name for drawer title
let displayName = 'Node Details';
try {
const hostname = nodeData.hostname || '';
const ip = nodeData.ip || '';
if (hostname && ip) {
displayName = `${hostname} - ${ip}`;
} else if (hostname) {
displayName = hostname;
} else if (ip) {
displayName = ip;
}
} catch (_) {}
// Open drawer with content callback
this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
// Mount NodeDetailsComponent
const nodeDetailsVM = new NodeDetailsViewModel();
const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
setActiveComponent(nodeDetailsComponent);
const ip = nodeData.ip || nodeData.id;
nodeDetailsVM.loadNodeDetails(ip).then(() => {
nodeDetailsComponent.mount();
}).catch((error) => {
logger.error('Failed to load node details (topology drawer):', error);
contentContainer.innerHTML = `
<div class="error">
<strong>Error loading node details:</strong><br>
${this.escapeHtml(error.message)}
</div>
`;
});
});
try {
if (window.TerminalPanel && typeof nodeData.ip === 'string') {
window.TerminalPanel.lastNodeIp = nodeData.ip;
if (window.TerminalPanel._updateTitle) {
window.TerminalPanel._updateTitle(nodeData.ip);
}
}
} catch (_) {}
}
closeDrawer() {
this.drawer.closeDrawer();
}
// Tooltip helpers
ensureTooltip() {
if (this.tooltipEl) return;
const el = document.createElement('div');
el.className = 'topology-tooltip';
document.body.appendChild(el);
this.tooltipEl = el;
}
showTooltip(nodeData, pageX, pageY) {
this.ensureTooltip();
const labels = (nodeData && nodeData.labels) ? nodeData.labels : ((nodeData && nodeData.resources) ? nodeData.resources : null);
if (!labels || Object.keys(labels).length === 0) {
this.hideTooltip();
return;
}
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>`;
this.positionTooltip(pageX, pageY);
this.tooltipEl.classList.add('visible');
}
positionTooltip(pageX, pageY) {
if (!this.tooltipEl) return;
const offset = 12;
let left = pageX + offset;
let top = pageY + offset;
const { innerWidth, innerHeight } = window;
const rect = this.tooltipEl.getBoundingClientRect();
if (left + rect.width > innerWidth - 8) left = pageX - rect.width - offset;
if (top + rect.height > innerHeight - 8) top = pageY - rect.height - offset;
this.tooltipEl.style.left = `${Math.max(8, left)}px`;
this.tooltipEl.style.top = `${Math.max(8, top)}px`;
}
moveTooltip(pageX, pageY) {
if (!this.tooltipEl || !this.tooltipEl.classList.contains('visible')) return;
this.positionTooltip(pageX, pageY);
}
hideTooltip() {
if (this.tooltipEl) this.tooltipEl.classList.remove('visible');
}
updateDimensions(container) {
// Get the container's actual dimensions
const rect = container.getBoundingClientRect();
this.width = rect.width || 1400; // Fallback to 1400 if width is 0
this.height = rect.height || 1000; // Fallback to 1000 if height is 0
// Ensure minimum dimensions
this.width = Math.max(this.width, 800);
this.height = Math.max(this.height, 600);
logger.debug('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height);
}
handleResize() {
// Debounce resize events to avoid excessive updates
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = setTimeout(() => {
const container = this.findElement('#topology-graph-container');
if (container && this.svg) {
this.updateDimensions(container);
// Update SVG viewBox and force center
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);
if (this.simulation) {
this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2));
this.simulation.alpha(0.3).restart();
}
}
}, 250); // 250ms debounce
}
// Override mount to ensure proper initialization
mount() {
if (this.isMounted) return;
logger.debug('TopologyGraphComponent: Starting mount...');
logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized);
// Call initialize if not already done
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Initializing during mount...');
this.initialize().then(() => {
logger.debug('TopologyGraphComponent: Initialization completed, calling completeMount...');
// Complete mount after initialization
this.completeMount();
}).catch(error => {
logger.error('TopologyGraphComponent: Initialization failed during mount:', error);
// Still complete mount to prevent blocking
this.completeMount();
});
} else {
logger.debug('TopologyGraphComponent: Already initialized, calling completeMount directly...');
this.completeMount();
}
}
completeMount() {
logger.debug('TopologyGraphComponent: completeMount called');
this.isMounted = true;
logger.debug('TopologyGraphComponent: Setting up event listeners...');
this.setupEventListeners();
logger.debug('TopologyGraphComponent: Setting up view model listeners...');
this.setupViewModelListeners();
logger.debug('TopologyGraphComponent: Calling render...');
this.render();
logger.debug('TopologyGraphComponent: Mounted successfully');
}
setupEventListeners() {
logger.debug('TopologyGraphComponent: setupEventListeners called');
logger.debug('TopologyGraphComponent: Container:', this.container);
logger.debug('TopologyGraphComponent: Container ID:', this.container?.id);
// Add resize listener to update dimensions when window is resized
this.resizeHandler = this.handleResize.bind(this);
window.addEventListener('resize', this.resizeHandler);
// Refresh button removed from HTML, so no need to set up event listeners
logger.debug('TopologyGraphComponent: No event listeners needed (refresh button removed)');
}
setupViewModelListeners() {
logger.debug('TopologyGraphComponent: setupViewModelListeners called');
logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized);
if (this.isInitialized) {
// Component is already initialized, set up subscriptions immediately
logger.debug('TopologyGraphComponent: Setting up property subscriptions immediately');
this.subscribeToProperty('nodes', this.renderGraph.bind(this));
this.subscribeToProperty('links', this.renderGraph.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this));
this.subscribeToProperty('error', this.handleError.bind(this));
this.subscribeToProperty('selectedNode', this.updateSelection.bind(this));
} else {
// Component not yet initialized, store for later
logger.debug('TopologyGraphComponent: Component not initialized, storing pending subscriptions');
this._pendingSubscriptions = [
['nodes', this.renderGraph.bind(this)],
['links', this.renderGraph.bind(this)],
['isLoading', this.handleLoadingState.bind(this)],
['error', this.handleError.bind(this)],
['selectedNode', this.updateSelection.bind(this)]
];
}
}
async initialize() {
logger.debug('TopologyGraphComponent: Initializing...');
// Wait for DOM to be ready
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve);
});
}
// Set up the SVG container
this.setupSVG();
// Mark as initialized
this.isInitialized = true;
// Now set up the actual property listeners after initialization
if (this._pendingSubscriptions) {
this._pendingSubscriptions.forEach(([property, callback]) => {
this.subscribeToProperty(property, callback);
});
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) {
logger.error('TopologyGraphComponent: Graph container not found');
return;
}
// Calculate dynamic dimensions based on container size
this.updateDimensions(container);
// 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(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('border-radius', '12px');
// Add zoom behavior
this.zoom = d3.zoom()
.scaleExtent([0.5, 5])
.on('zoom', (event) => {
this.svg.select('g').attr('transform', event.transform);
});
this.svg.call(this.zoom);
// Create main group for zoom and apply initial zoom
const mainGroup = this.svg.append('g');
// Apply initial zoom to show the graph more zoomed in
mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)');
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) {
logger.debug('TopologyGraphComponent: Ensuring initialization...');
await this.initialize();
}
return this.isInitialized;
}
renderGraph() {
try {
// Check if component is initialized
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
this.ensureInitialized().then(() => {
// Re-render after initialization
this.renderGraph();
}).catch(error => {
logger.error('TopologyGraphComponent: Failed to initialize:', error);
});
return;
}
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');
this.setupSVG();
}
if (!nodes || nodes.length === 0) {
this.showNoData();
return;
}
logger.debug('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links');
// Get the main SVG group (the one created in setupSVG)
let svgGroup = this.svg.select('g');
if (!svgGroup || svgGroup.empty()) {
logger.debug('TopologyGraphComponent: Creating new SVG group');
svgGroup = this.svg.append('g');
// Apply initial zoom to show the graph more zoomed in
svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)');
}
// 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) {
logger.error('Failed to render graph:', error);
}
}
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
// D3's forceLink mutates source/target from strings to objects, so we need to normalize the key
const link = linkGroup.selectAll('line')
.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}`;
});
// 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 with same key function as updateLinks for consistency
const linkLabels = linkLabelGroup.selectAll('text')
.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;
return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`;
});
// 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', 'legend-group graph-element')
.attr('transform', `translate(120, 120)`) // Hidden by CSS opacity
.style('opacity', '0');
legend.append('rect')
.attr('width', 320)
.attr('height', 120)
.attr('fill', 'rgba(0, 0, 0, 0.7)')
.attr('rx', 8)
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
.attr('stroke-width', 1);
const nodeLegend = legend.append('g')
.attr('transform', 'translate(20, 20)');
nodeLegend.append('text')
.text('Node Status:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px')
.attr('font-weight', '600')
.attr('fill', '#ecf0f1');
const statuses = [
{ status: 'ACTIVE', color: '#10b981', y: 20 },
{ status: 'INACTIVE', color: '#f59e0b', y: 40 },
{ status: 'DEAD', color: '#ef4444', y: 60 }
];
statuses.forEach(item => {
nodeLegend.append('circle')
.attr('r', 6)
.attr('cx', 0)
.attr('cy', item.y)
.attr('fill', item.color);
nodeLegend.append('text')
.text(item.status)
.attr('x', 15)
.attr('y', item.y + 4)
.attr('font-size', '12px')
.attr('fill', '#ecf0f1');
});
const linkLegend = legend.append('g')
.attr('transform', 'translate(150, 20)');
linkLegend.append('text')
.text('Link Latency:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px')
.attr('font-weight', '600')
.attr('fill', '#ecf0f1');
const latencies = [
{ range: '≤30ms', color: '#10b981', y: 20 },
{ range: '31-50ms', color: '#f59e0b', y: 40 },
{ range: '>50ms', color: '#ef4444', y: 60 }
];
latencies.forEach(item => {
linkLegend.append('line')
.attr('x1', 0)
.attr('y1', item.y)
.attr('x2', 20)
.attr('y2', item.y)
.attr('stroke', item.color)
.attr('stroke-width', 2);
linkLegend.append('text')
.text(item.range)
.attr('x', 25)
.attr('y', item.y + 4)
.attr('font-size', '12px')
.attr('fill', '#ecf0f1');
});
}
getNodeRadius(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return 10;
case 'INACTIVE':
return 8;
case 'DEAD':
return 6;
default:
return 8;
}
}
getStatusIndicatorColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#10b981';
case 'INACTIVE':
return '#f59e0b';
case 'DEAD':
return '#ef4444';
default:
return '#6b7280';
}
}
getLinkColor(latency) {
if (latency <= 50) return '#10b981';
if (latency <= 100) return '#f59e0b';
return '#ef4444';
}
getNodeColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#10b981';
case 'INACTIVE':
return '#f59e0b';
case 'DEAD':
return '#ef4444';
default:
return '#6b7280';
}
}
drag(simulation) {
return d3.drag()
.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', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.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) {
return;
}
this.svg.selectAll('.node').select('circle')
.attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2)
.attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff');
}
// NOTE: This method is deprecated and should not be used
// The topology graph is now entirely websocket-driven
// Refresh button was removed and all updates come from websocket events
handleRefresh() {
logger.warn('TopologyGraphComponent: handleRefresh called - this method is deprecated');
logger.warn('TopologyGraphComponent: Topology updates should come from websocket events only');
// No-op - do not make API calls
}
handleLoadingState(isLoading) {
logger.debug('TopologyGraphComponent: handleLoadingState called with:', isLoading);
const container = this.findElement('#topology-graph-container');
if (isLoading) {
// 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');
}
}
}
handleError() {
const error = this.viewModel.get('error');
if (error) {
const container = this.findElement('#topology-graph-container');
container.innerHTML = `<div class="error"><div>Error: ${error}</div></div>`;
}
}
showNoData() {
const container = this.findElement('#topology-graph-container');
// 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) {
// Create overlay container if it doesn't exist
let overlayContainer = document.getElementById('member-card-overlay');
if (!overlayContainer) {
overlayContainer = document.createElement('div');
overlayContainer.id = 'member-card-overlay';
overlayContainer.className = 'member-card-overlay';
document.body.appendChild(overlayContainer);
}
// Create and show the overlay component
if (!this.memberOverlayComponent) {
const overlayVM = new ViewModel();
this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus);
this.memberOverlayComponent.mount();
}
// Convert node data to member data format
const memberData = {
ip: nodeData.ip,
hostname: nodeData.hostname,
status: this.normalizeStatus(nodeData.status),
latency: nodeData.latency,
labels: (nodeData.labels && typeof nodeData.labels === 'object') ? nodeData.labels : (nodeData.resources || {})
};
this.memberOverlayComponent.show(memberData);
}
// Normalize status from topology format to member card format
normalizeStatus(status) {
if (!status) return 'unknown';
const normalized = status.toLowerCase();
switch (normalized) {
case 'active':
return 'active';
case 'inactive':
return 'inactive';
case 'dead':
return 'offline';
default:
return 'unknown';
}
}
// Override render method to display the graph
render() {
logger.debug('TopologyGraphComponent: render called');
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Not initialized yet, skipping render');
return;
}
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 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();
}
}
unmount() {
// Clean up resize listener
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
this.resizeHandler = null;
}
// Clear resize timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}
// Clear dragged node positions
if (this.draggedNodePositions) {
this.draggedNodePositions.clear();
}
// Call parent unmount
super.unmount();
}
}
// Minimal Member Card Overlay Component (kept in same file to avoid circular loads)
class MemberCardOverlayComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.isVisible = false;
this.currentMember = null;
}
mount() {
super.mount();
this.setupEventListeners();
}
setupEventListeners() {
// Close overlay when clicking outside or pressing escape
this.addEventListener(this.container, 'click', (e) => {
if (!this.isVisible) return;
if (e.target === this.container) {
this.hide();
}
});
this.addEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape' && this.isVisible) {
this.hide();
}
});
}
show(memberData) {
this.currentMember = memberData;
this.isVisible = true;
const memberCardHTML = this.renderMemberCard(memberData);
this.setHTML('', memberCardHTML);
setTimeout(() => {
this.container.classList.add('visible');
}, 10);
this.setupMemberCardInteractions();
}
hide() {
this.isVisible = false;
this.container.classList.remove('visible');
this.currentMember = null;
}
renderMemberCard(member) {
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' :
(member.status && member.status.toUpperCase() === 'INACTIVE') ? 'status-inactive' : 'status-offline';
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' :
(member.status && member.status.toUpperCase() === 'INACTIVE') ? '🟠' : '🔴';
return `
<div class="member-overlay-content">
<div class="member-overlay-header">
<div class="member-info">
<div class="member-row-1">
<div class="status-hostname-group">
<div class="member-status ${statusClass}">
${statusIcon}
</div>
<div class="member-hostname">${member.hostname || 'Unknown Device'}</div>
</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
</div>
<div class="member-labels" style="display: none;"></div>
</div>
<button class="member-overlay-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div class="member-overlay-body">
<div class="member-card expanded" data-member-ip="${member.ip}">
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
</div>
</div>
`;
}
setupMemberCardInteractions() {
const closeBtn = this.findElement('.member-overlay-close');
if (closeBtn) {
this.addEventListener(closeBtn, 'click', () => {
this.hide();
});
}
setTimeout(async () => {
const memberCard = this.findElement('.member-card');
if (memberCard) {
const memberDetails = memberCard.querySelector('.member-details');
const memberIp = memberCard.dataset.memberIp;
await this.expandCard(memberCard, memberIp, memberDetails);
}
}, 100);
}
async expandCard(card, memberIp, memberDetails) {
try {
const nodeDetailsVM = new NodeDetailsViewModel();
const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus);
await nodeDetailsVM.loadNodeDetails(memberIp);
const nodeStatus = nodeDetailsVM.get('nodeStatus');
if (nodeStatus && nodeStatus.labels) {
const labelsContainer = document.querySelector('.member-overlay-header .member-labels');
if (labelsContainer) {
labelsContainer.innerHTML = Object.entries(nodeStatus.labels)
.map(([key, value]) => `<span class="label-chip">${key}: ${value}</span>`)
.join('');
labelsContainer.style.display = 'block';
}
}
nodeDetailsComponent.mount();
card.classList.add('expanded');
} catch (error) {
logger.error('Failed to expand member card:', error);
card.classList.add('expanded');
const details = card.querySelector('.member-details');
if (details) {
details.innerHTML = '<div class="error">Failed to load node details</div>';
}
}
}
}
window.TopologyGraphComponent = TopologyGraphComponent;
window.MemberCardOverlayComponent = MemberCardOverlayComponent;