feat: add topology view
This commit is contained in:
@@ -2267,4 +2267,576 @@ class ClusterStatusComponent extends Component {
|
||||
this.container.classList.add(statusClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Topology Graph Component with D3.js force-directed visualization
|
||||
class TopologyGraphComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
console.log('TopologyGraphComponent: Constructor called');
|
||||
this.svg = null;
|
||||
this.simulation = null;
|
||||
this.zoom = null;
|
||||
this.width = 1400; // Increased from 1000 for more space
|
||||
this.height = 1000; // Increased from 800 for more space
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
// Override mount to ensure proper initialization
|
||||
mount() {
|
||||
if (this.isMounted) return;
|
||||
|
||||
console.log('TopologyGraphComponent: Starting mount...');
|
||||
console.log('TopologyGraphComponent: isInitialized =', this.isInitialized);
|
||||
|
||||
// Call initialize if not already done
|
||||
if (!this.isInitialized) {
|
||||
console.log('TopologyGraphComponent: Initializing during mount...');
|
||||
this.initialize().then(() => {
|
||||
console.log('TopologyGraphComponent: Initialization completed, calling completeMount...');
|
||||
// Complete mount after initialization
|
||||
this.completeMount();
|
||||
}).catch(error => {
|
||||
console.error('TopologyGraphComponent: Initialization failed during mount:', error);
|
||||
// Still complete mount to prevent blocking
|
||||
this.completeMount();
|
||||
});
|
||||
} else {
|
||||
console.log('TopologyGraphComponent: Already initialized, calling completeMount directly...');
|
||||
this.completeMount();
|
||||
}
|
||||
}
|
||||
|
||||
completeMount() {
|
||||
console.log('TopologyGraphComponent: completeMount called');
|
||||
this.isMounted = true;
|
||||
console.log('TopologyGraphComponent: Setting up event listeners...');
|
||||
this.setupEventListeners();
|
||||
console.log('TopologyGraphComponent: Setting up view model listeners...');
|
||||
this.setupViewModelListeners();
|
||||
console.log('TopologyGraphComponent: Calling render...');
|
||||
this.render();
|
||||
|
||||
console.log('TopologyGraphComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
console.log('TopologyGraphComponent: setupEventListeners called');
|
||||
console.log('TopologyGraphComponent: Container:', this.container);
|
||||
console.log('TopologyGraphComponent: Container ID:', this.container?.id);
|
||||
|
||||
// Refresh button removed from HTML, so no need to set up event listeners
|
||||
console.log('TopologyGraphComponent: No event listeners needed (refresh button removed)');
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
console.log('TopologyGraphComponent: setupViewModelListeners called');
|
||||
console.log('TopologyGraphComponent: isInitialized =', this.isInitialized);
|
||||
|
||||
if (this.isInitialized) {
|
||||
// Component is already initialized, set up subscriptions immediately
|
||||
console.log('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
|
||||
console.log('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() {
|
||||
console.log('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;
|
||||
}
|
||||
|
||||
// Initial data load
|
||||
await this.viewModel.updateNetworkTopology();
|
||||
}
|
||||
|
||||
setupSVG() {
|
||||
const container = this.findElement('#topology-graph-container');
|
||||
if (!container) {
|
||||
console.error('TopologyGraphComponent: Graph container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing content
|
||||
container.innerHTML = '';
|
||||
|
||||
// Create SVG element
|
||||
this.svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('width', this.width)
|
||||
.attr('height', this.height)
|
||||
.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
|
||||
this.zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 5]) // Changed from [0.3, 4] to allow more zoom in and start more zoomed in
|
||||
.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)'); // Better centered positioning
|
||||
|
||||
console.log('TopologyGraphComponent: SVG setup completed');
|
||||
}
|
||||
|
||||
// Ensure component is initialized
|
||||
async ensureInitialized() {
|
||||
if (!this.isInitialized) {
|
||||
console.log('TopologyGraphComponent: Ensuring initialization...');
|
||||
await this.initialize();
|
||||
}
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
renderGraph() {
|
||||
try {
|
||||
// Check if component is initialized
|
||||
if (!this.isInitialized) {
|
||||
console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
|
||||
this.ensureInitialized().then(() => {
|
||||
// Re-render after initialization
|
||||
this.renderGraph();
|
||||
}).catch(error => {
|
||||
console.error('TopologyGraphComponent: Failed to initialize:', error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = this.viewModel.get('nodes');
|
||||
const links = this.viewModel.get('links');
|
||||
|
||||
// Check if SVG is initialized
|
||||
if (!this.svg) {
|
||||
console.log('TopologyGraphComponent: SVG not initialized yet, setting up SVG first');
|
||||
this.setupSVG();
|
||||
}
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
this.showNoData();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('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()) {
|
||||
console.log('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)'); // Better centered positioning
|
||||
}
|
||||
|
||||
// Clear existing graph elements but preserve the main group and its transform
|
||||
svgGroup.selectAll('.graph-element').remove();
|
||||
|
||||
// Create links with better styling (no arrows needed)
|
||||
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) // Reduced from 0.8 for subtlety
|
||||
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Much thinner: reduced from max 6 to max 3
|
||||
.attr('marker-end', null); // Remove arrows
|
||||
|
||||
// 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 with size based on status
|
||||
node.append('circle')
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Add status indicator
|
||||
node.append('circle')
|
||||
.attr('r', 3)
|
||||
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
||||
.attr('cx', -8)
|
||||
.attr('cy', -8);
|
||||
|
||||
// Add labels to nodes
|
||||
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') // Increased from 12px for better readability
|
||||
.attr('fill', '#ecf0f1') // Light text for dark theme
|
||||
.attr('font-weight', '500');
|
||||
|
||||
// Add IP address labels
|
||||
node.append('text')
|
||||
.text(d => d.ip)
|
||||
.attr('x', 15)
|
||||
.attr('y', 20)
|
||||
.attr('font-size', '11px') // Increased from 10px for better readability
|
||||
.attr('fill', 'rgba(255, 255, 255, 0.7)'); // Semi-transparent white
|
||||
|
||||
// Add status labels
|
||||
node.append('text')
|
||||
.text(d => d.status)
|
||||
.attr('x', 15)
|
||||
.attr('y', 35)
|
||||
.attr('font-size', '11px') // Increased from 10px for better readability
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('font-weight', '600');
|
||||
|
||||
// Add latency labels on links with better positioning
|
||||
const linkLabels = svgGroup.append('g')
|
||||
.attr('class', 'graph-element')
|
||||
.selectAll('text')
|
||||
.data(links)
|
||||
.enter().append('text')
|
||||
.attr('font-size', '12px') // Increased from 11px for better readability
|
||||
.attr('fill', '#ecf0f1') // Light text for dark theme
|
||||
.attr('font-weight', '600') // Made slightly bolder for better readability
|
||||
.attr('text-anchor', 'middle')
|
||||
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') // Add shadow for better contrast
|
||||
.text(d => `${d.latency}ms`);
|
||||
|
||||
// Remove the background boxes for link labels - they look out of place
|
||||
|
||||
// Set up force simulation with better parameters (only if not already exists)
|
||||
if (!this.simulation) {
|
||||
this.simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(300)) // Increased from 200 for more spacing
|
||||
.force('charge', d3.forceManyBody().strength(-800)) // Increased from -600 for stronger repulsion
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force('collision', d3.forceCollide().radius(80)); // Increased from 60 for more separation
|
||||
|
||||
// Update positions on simulation tick
|
||||
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);
|
||||
|
||||
// Update link labels
|
||||
linkLabels
|
||||
.attr('x', d => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
|
||||
|
||||
// Remove the background update code since we removed the backgrounds
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
} else {
|
||||
// Update existing simulation with new data
|
||||
this.simulation.nodes(nodes);
|
||||
this.simulation.force('link').links(links);
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
// Add click handlers for node selection
|
||||
node.on('click', (event, d) => {
|
||||
this.viewModel.selectNode(d.id);
|
||||
this.updateSelection(d.id);
|
||||
});
|
||||
|
||||
// Add hover effects
|
||||
node.on('mouseover', (event, d) => {
|
||||
d3.select(event.currentTarget).select('circle')
|
||||
.attr('r', d => this.getNodeRadius(d.status) + 4)
|
||||
.attr('stroke-width', 3);
|
||||
});
|
||||
|
||||
node.on('mouseout', (event, d) => {
|
||||
d3.select(event.currentTarget).select('circle')
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('stroke-width', 2);
|
||||
});
|
||||
|
||||
// Add tooltip for links
|
||||
link.on('mouseover', (event, d) => {
|
||||
d3.select(event.currentTarget)
|
||||
.attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6))) // Reduced from max 10 to max 4
|
||||
.attr('stroke-opacity', 0.9); // Reduced from 1 for subtlety
|
||||
});
|
||||
|
||||
link.on('mouseout', (event, d) => {
|
||||
d3.select(event.currentTarget)
|
||||
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Reduced from max 8 to max 3
|
||||
.attr('stroke-opacity', 0.7); // Reduced from 0.7 for consistency
|
||||
});
|
||||
|
||||
// Add legend
|
||||
this.addLegend(svgGroup);
|
||||
} catch (error) {
|
||||
console.error('Failed to render graph:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addLegend(svgGroup) {
|
||||
const legend = svgGroup.append('g')
|
||||
.attr('class', 'graph-element')
|
||||
.attr('transform', `translate(120, 120)`) // Increased from (80, 80) for more space from edges
|
||||
.style('opacity', '0'); // Hide the legend but keep it in the code
|
||||
|
||||
// Add background for better visibility
|
||||
legend.append('rect')
|
||||
.attr('width', 320) // Increased from 280 for more space
|
||||
.attr('height', 120) // Increased from 100 for more space
|
||||
.attr('fill', 'rgba(0, 0, 0, 0.7)')
|
||||
.attr('rx', 8)
|
||||
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
// Node status legend
|
||||
const nodeLegend = legend.append('g')
|
||||
.attr('transform', 'translate(20, 20)'); // Increased from (15, 15) for more internal padding
|
||||
|
||||
nodeLegend.append('text')
|
||||
.text('Node Status:')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('font-size', '14px') // Increased from 13px for better readability
|
||||
.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') // Increased from 11px for better readability
|
||||
.attr('fill', '#ecf0f1');
|
||||
});
|
||||
|
||||
// Link latency legend
|
||||
const linkLegend = legend.append('g')
|
||||
.attr('transform', 'translate(150, 20)'); // Adjusted position for better spacing
|
||||
|
||||
linkLegend.append('text')
|
||||
.text('Link Latency:')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('font-size', '14px') // Increased from 13px for better readability
|
||||
.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); // Reduced from 3 to match the thinner graph lines
|
||||
|
||||
linkLegend.append('text')
|
||||
.text(item.range)
|
||||
.attr('x', 25)
|
||||
.attr('y', item.y + 4)
|
||||
.attr('font-size', '12px') // Increased from 11px for better readability
|
||||
.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'; // Green
|
||||
case 'INACTIVE':
|
||||
return '#f59e0b'; // Orange
|
||||
case 'DEAD':
|
||||
return '#ef4444'; // Red
|
||||
default:
|
||||
return '#6b7280'; // Gray
|
||||
}
|
||||
}
|
||||
|
||||
getLinkColor(latency) {
|
||||
if (latency <= 30) return '#10b981'; // Green for low latency (≤30ms)
|
||||
if (latency <= 50) return '#f59e0b'; // Orange for medium latency (31-50ms)
|
||||
return '#ef4444'; // Red for high latency (>50ms)
|
||||
}
|
||||
|
||||
getNodeColor(status) {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'ACTIVE':
|
||||
return '#10b981'; // Green
|
||||
case 'INACTIVE':
|
||||
return '#f59e0b'; // Orange
|
||||
case 'DEAD':
|
||||
return '#ef4444'; // Red
|
||||
default:
|
||||
return '#6b7280'; // Gray
|
||||
}
|
||||
}
|
||||
|
||||
drag(simulation) {
|
||||
return d3.drag()
|
||||
.on('start', function(event, d) {
|
||||
if (!event.active && simulation && simulation.alphaTarget) {
|
||||
simulation.alphaTarget(0.3).restart();
|
||||
}
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on('drag', function(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on('end', function(event, d) {
|
||||
if (!event.active && simulation && simulation.alphaTarget) {
|
||||
simulation.alphaTarget(0);
|
||||
}
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
handleRefresh() {
|
||||
console.log('TopologyGraphComponent: handleRefresh called');
|
||||
|
||||
if (!this.isInitialized) {
|
||||
console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
|
||||
this.ensureInitialized().then(() => {
|
||||
// Refresh after initialization
|
||||
this.viewModel.updateNetworkTopology();
|
||||
}).catch(error => {
|
||||
console.error('TopologyGraphComponent: Failed to initialize for refresh:', error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('TopologyGraphComponent: Calling updateNetworkTopology...');
|
||||
this.viewModel.updateNetworkTopology();
|
||||
}
|
||||
|
||||
handleLoadingState(isLoading) {
|
||||
console.log('TopologyGraphComponent: handleLoadingState called with:', isLoading);
|
||||
const container = this.findElement('#topology-graph-container');
|
||||
|
||||
if (isLoading) {
|
||||
container.innerHTML = '<div class="loading"><div>Loading network topology...</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
|
||||
}
|
||||
|
||||
// Override render method to display the graph
|
||||
render() {
|
||||
console.log('TopologyGraphComponent: render called');
|
||||
if (!this.isInitialized) {
|
||||
console.log('TopologyGraphComponent: Not initialized yet, skipping render');
|
||||
return;
|
||||
}
|
||||
const nodes = this.viewModel.get('nodes');
|
||||
const links = this.viewModel.get('links');
|
||||
if (nodes && nodes.length > 0) {
|
||||
console.log('TopologyGraphComponent: Rendering graph with data');
|
||||
this.renderGraph();
|
||||
} else {
|
||||
console.log('TopologyGraphComponent: No data available, showing loading state');
|
||||
this.handleLoadingState(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user