feat: add topology view
This commit is contained in:
@@ -11,9 +11,9 @@ Zero-configuration web interface for monitoring and managing SPORE embedded syst
|
||||
|
||||
## Screenshots
|
||||
### Cluster
|
||||

|
||||
### Capabilities
|
||||

|
||||

|
||||
### Topology
|
||||

|
||||
### Firmware
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 212 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 327 KiB |
BIN
assets/topology.png
Normal file
BIN
assets/topology.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
146
docs/TOPOLOGY_VIEW.md
Normal file
146
docs/TOPOLOGY_VIEW.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Topology View - Network Topology Visualization
|
||||
|
||||
## Overview
|
||||
|
||||
The Topology view provides an interactive, force-directed graph visualization of the SPORE cluster network topology. It displays each cluster member as a node and shows the connections (links) between them with latency information.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎯 **Interactive Network Graph**
|
||||
- **Force-directed layout**: Nodes automatically arrange themselves based on connections
|
||||
- **Zoom and pan**: Navigate through large network topologies
|
||||
- **Drag and drop**: Reposition nodes manually for better visualization
|
||||
- **Responsive design**: Adapts to different screen sizes
|
||||
|
||||
### 📊 **Node Information**
|
||||
- **Status indicators**: Color-coded nodes based on member status (ACTIVE, INACTIVE, DEAD)
|
||||
- **Hostname display**: Shows the human-readable name of each node
|
||||
- **IP addresses**: Displays the network address of each member
|
||||
- **Resource information**: Access to system resources and capabilities
|
||||
|
||||
### 🔗 **Connection Visualization**
|
||||
- **Latency display**: Shows network latency between connected nodes
|
||||
- **Color-coded links**: Different colors indicate latency ranges:
|
||||
- 🟢 Green: ≤5ms (excellent)
|
||||
- 🟠 Orange: 6-15ms (good)
|
||||
- 🔴 Red-orange: 16-30ms (fair)
|
||||
- 🔴 Red: >30ms (poor)
|
||||
- **Bidirectional connections**: Shows actual network topology from each node's perspective
|
||||
|
||||
### 🎨 **Visual Enhancements**
|
||||
- **Legend**: Explains node status colors and latency ranges
|
||||
- **Hover effects**: Interactive feedback when hovering over nodes and links
|
||||
- **Selection highlighting**: Click nodes to select and highlight them
|
||||
- **Smooth animations**: Force simulation provides natural movement
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Architecture
|
||||
- **ViewModel**: `TopologyViewModel` manages data and state
|
||||
- **Component**: `TopologyGraphComponent` handles rendering and interactions
|
||||
- **Framework**: Integrates with the existing SPORE UI framework
|
||||
- **Library**: Uses D3.js v7 for graph visualization
|
||||
|
||||
### Data Flow
|
||||
1. **Primary node query**: Fetches cluster members from the primary node
|
||||
2. **Individual node queries**: Gets cluster view from each member node
|
||||
3. **Topology building**: Constructs network graph from actual connections
|
||||
4. **Fallback mesh**: Creates basic mesh if no actual connections found
|
||||
|
||||
### API Endpoints
|
||||
- `/api/cluster/members` - Get cluster membership from primary node
|
||||
- `/api/cluster/members?ip={nodeIP}` - Get cluster view from specific node
|
||||
|
||||
## Usage
|
||||
|
||||
### Navigation
|
||||
1. Click the "🌐 Topology" tab in the main navigation
|
||||
2. The view automatically loads and displays the network topology
|
||||
3. Use the refresh button to update the visualization
|
||||
|
||||
### Interaction
|
||||
- **Zoom**: Use mouse wheel or pinch gestures
|
||||
- **Pan**: Click and drag on empty space
|
||||
- **Select nodes**: Click on any node to highlight it
|
||||
- **Move nodes**: Drag nodes to reposition them
|
||||
- **Hover**: Hover over nodes and links for additional information
|
||||
|
||||
### Refresh
|
||||
- Click the "Refresh" button to reload network topology data
|
||||
- Useful after network changes or when adding/removing nodes
|
||||
|
||||
## Configuration
|
||||
|
||||
### Graph Parameters
|
||||
- **Node spacing**: 120px between connected nodes
|
||||
- **Repulsion force**: -400 strength for node separation
|
||||
- **Collision radius**: 40px minimum distance between nodes
|
||||
- **Zoom limits**: 0.1x to 4x zoom range
|
||||
|
||||
### Visual Settings
|
||||
- **Node sizes**: Vary based on status (ACTIVE: 10px, INACTIVE: 8px, DEAD: 6px)
|
||||
- **Link thickness**: Proportional to latency (2-8px range)
|
||||
- **Colors**: Semantic color scheme for status and latency
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### No Graph Displayed
|
||||
- Check browser console for JavaScript errors
|
||||
- Verify D3.js library is loading correctly
|
||||
- Ensure cluster has discovered nodes
|
||||
|
||||
#### Missing Connections
|
||||
- Verify nodes are responding to API calls
|
||||
- Check network connectivity between nodes
|
||||
- Review cluster discovery configuration
|
||||
|
||||
#### Performance Issues
|
||||
- Reduce number of displayed nodes
|
||||
- Adjust force simulation parameters
|
||||
- Use zoom to focus on specific areas
|
||||
|
||||
### Debug Information
|
||||
- Test file available at `test-topology-view.html`
|
||||
- Console logging provides detailed component lifecycle information
|
||||
- Network topology data is logged during updates
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- **Real-time updates**: WebSocket integration for live topology changes
|
||||
- **Metrics overlay**: CPU, memory, and network usage display
|
||||
- **Path finding**: Show routes between specific nodes
|
||||
- **Export options**: Save graph as image or data file
|
||||
- **Custom layouts**: Alternative visualization algorithms
|
||||
|
||||
### Performance Optimizations
|
||||
- **Lazy loading**: Load node details on demand
|
||||
- **Virtualization**: Handle large numbers of nodes efficiently
|
||||
- **Caching**: Store topology data locally
|
||||
- **Web Workers**: Offload computation to background threads
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **D3.js v7**: Force-directed graph visualization
|
||||
- **SPORE UI Framework**: Component architecture and state management
|
||||
- **Modern Browser**: ES6+ support required
|
||||
- **Network Access**: Ability to reach cluster nodes
|
||||
|
||||
## Browser Support
|
||||
|
||||
- **Chrome**: 80+ (recommended)
|
||||
- **Firefox**: 75+
|
||||
- **Safari**: 13+
|
||||
- **Edge**: 80+
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute to the Members view:
|
||||
|
||||
1. Follow the existing code style and patterns
|
||||
2. Test with different cluster configurations
|
||||
3. Ensure responsive design works on mobile devices
|
||||
4. Add appropriate error handling and logging
|
||||
5. Update documentation for new features
|
||||
@@ -45,6 +45,17 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getClusterMembersFromNode(ip) {
|
||||
try {
|
||||
return await this.request(`/api/cluster/members`, {
|
||||
method: 'GET',
|
||||
query: { ip: ip }
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscoveryInfo() {
|
||||
try {
|
||||
return await this.request('/api/discovery/nodes', { method: 'GET' });
|
||||
|
||||
@@ -12,7 +12,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('App: Creating view models...');
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
const firmwareViewModel = new FirmwareViewModel();
|
||||
console.log('App: View models created:', { clusterViewModel, firmwareViewModel });
|
||||
const topologyViewModel = new TopologyViewModel();
|
||||
console.log('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel });
|
||||
|
||||
// Connect firmware view model to cluster data
|
||||
clusterViewModel.subscribe('members', (members) => {
|
||||
@@ -35,6 +36,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Register routes with their view models
|
||||
console.log('App: Registering routes...');
|
||||
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
||||
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||
console.log('App: Routes registered and components pre-initialized');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
2
public/d3.v7.min.js
vendored
Normal file
2
public/d3.v7.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
124
public/demo-topology-data.js
Normal file
124
public/demo-topology-data.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// Demo data for testing the Members view without actual cluster data
|
||||
window.demoMembersData = {
|
||||
members: [
|
||||
{
|
||||
hostname: "spore-node-1",
|
||||
ip: "192.168.1.100",
|
||||
lastSeen: Date.now(),
|
||||
latency: 3,
|
||||
status: "ACTIVE",
|
||||
resources: {
|
||||
freeHeap: 48748,
|
||||
chipId: 12345678,
|
||||
sdkVersion: "3.1.2",
|
||||
cpuFreqMHz: 80,
|
||||
flashChipSize: 1048576
|
||||
},
|
||||
api: [
|
||||
{ uri: "/api/node/status", method: "GET" },
|
||||
{ uri: "/api/tasks/status", method: "GET" }
|
||||
]
|
||||
},
|
||||
{
|
||||
hostname: "spore-node-2",
|
||||
ip: "192.168.1.101",
|
||||
lastSeen: Date.now() - 5000,
|
||||
latency: 8,
|
||||
status: "ACTIVE",
|
||||
resources: {
|
||||
freeHeap: 52340,
|
||||
chipId: 87654321,
|
||||
sdkVersion: "3.1.2",
|
||||
cpuFreqMHz: 80,
|
||||
flashChipSize: 1048576
|
||||
},
|
||||
api: [
|
||||
{ uri: "/api/node/status", method: "GET" },
|
||||
{ uri: "/api/tasks/status", method: "GET" }
|
||||
]
|
||||
},
|
||||
{
|
||||
hostname: "spore-node-3",
|
||||
ip: "192.168.1.102",
|
||||
lastSeen: Date.now() - 15000,
|
||||
latency: 12,
|
||||
status: "INACTIVE",
|
||||
resources: {
|
||||
freeHeap: 38920,
|
||||
chipId: 11223344,
|
||||
sdkVersion: "3.1.1",
|
||||
cpuFreqMHz: 80,
|
||||
flashChipSize: 1048576
|
||||
},
|
||||
api: [
|
||||
{ uri: "/api/node/status", method: "GET" }
|
||||
]
|
||||
},
|
||||
{
|
||||
hostname: "spore-node-4",
|
||||
ip: "192.168.1.103",
|
||||
lastSeen: Date.now() - 30000,
|
||||
latency: 25,
|
||||
status: "ACTIVE",
|
||||
resources: {
|
||||
freeHeap: 45678,
|
||||
chipId: 55667788,
|
||||
sdkVersion: "3.1.2",
|
||||
cpuFreqMHz: 80,
|
||||
flashChipSize: 1048576
|
||||
},
|
||||
api: [
|
||||
{ uri: "/api/node/status", method: "GET" },
|
||||
{ uri: "/api/tasks/status", method: "GET" },
|
||||
{ uri: "/api/capabilities", method: "GET" }
|
||||
]
|
||||
},
|
||||
{
|
||||
hostname: "spore-node-5",
|
||||
ip: "192.168.1.104",
|
||||
lastSeen: Date.now() - 60000,
|
||||
latency: 45,
|
||||
status: "DEAD",
|
||||
resources: {
|
||||
freeHeap: 0,
|
||||
chipId: 99887766,
|
||||
sdkVersion: "3.1.0",
|
||||
cpuFreqMHz: 0,
|
||||
flashChipSize: 1048576
|
||||
},
|
||||
api: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Mock API client for demo purposes
|
||||
window.demoApiClient = {
|
||||
async getClusterMembers() {
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
return window.demoMembersData;
|
||||
},
|
||||
|
||||
async getClusterMembersFromNode(ip) {
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Return a subset of members to simulate different node perspectives
|
||||
const allMembers = window.demoMembersData.members;
|
||||
const nodeIndex = allMembers.findIndex(m => m.ip === ip);
|
||||
|
||||
if (nodeIndex === -1) {
|
||||
return { members: [] };
|
||||
}
|
||||
|
||||
// Simulate each node seeing a different subset of the cluster
|
||||
const startIndex = (nodeIndex * 2) % allMembers.length;
|
||||
const members = [
|
||||
allMembers[startIndex],
|
||||
allMembers[(startIndex + 1) % allMembers.length],
|
||||
allMembers[(startIndex + 2) % allMembers.length]
|
||||
];
|
||||
|
||||
return { members: members.filter(m => m.ip !== ip) };
|
||||
}
|
||||
};
|
||||
641
public/demo-topology-view.html
Normal file
641
public/demo-topology-view.html
Normal file
@@ -0,0 +1,641 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Demo Members View - SPORE UI</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<script src="demo-members-data.js"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="main-navigation">
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab" onclick="showCluster()">🌐 Cluster</button>
|
||||
<button class="nav-tab active" onclick="showTopology()">🌐 Topology</button>
|
||||
<button class="nav-tab" onclick="showFirmware()">📦 Firmware</button>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="cluster-status">🚀 Demo Mode</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cluster-view" class="view-content">
|
||||
<div class="cluster-section">
|
||||
<h2>🌐 Cluster View (Demo)</h2>
|
||||
<p>This is a demo of the cluster view. Switch to Members to see the network topology visualization.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="topology-view" class="view-content active">
|
||||
<div class="topology-section">
|
||||
<div class="topology-header">
|
||||
<h2>🌐 Network Topology (Demo)</h2>
|
||||
<button class="refresh-btn" onclick="refreshTopologyView()">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="topology-graph-container">
|
||||
<div class="loading">
|
||||
<div>Loading network topology...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<h2>📦 Firmware View (Demo)</h2>
|
||||
<p>This is a demo of the firmware view. Switch to Members to see the network topology visualization.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Demo Topology View Implementation
|
||||
class DemoTopologyViewModel {
|
||||
constructor() {
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
this.isLoading = false;
|
||||
this.error = null;
|
||||
this.selectedNode = null;
|
||||
}
|
||||
|
||||
async updateNetworkTopology() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
// Use demo API client
|
||||
const response = await window.demoApiClient.getClusterMembers();
|
||||
const members = response.members || [];
|
||||
|
||||
// Build enhanced graph data
|
||||
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
||||
|
||||
this.nodes = nodes;
|
||||
this.links = links;
|
||||
|
||||
// Trigger render
|
||||
if (window.topologyGraphComponent) {
|
||||
window.topologyGraphComponent.renderGraph();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network topology:', error);
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async buildEnhancedGraphData(members) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const nodeConnections = new Map();
|
||||
|
||||
// Create nodes from members
|
||||
members.forEach((member, index) => {
|
||||
if (member && member.ip) {
|
||||
nodes.push({
|
||||
id: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
ip: member.ip,
|
||||
status: member.status || 'UNKNOWN',
|
||||
latency: member.latency || 0,
|
||||
resources: member.resources || {},
|
||||
x: Math.random() * 800 + 100,
|
||||
y: Math.random() * 600 + 100
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Try to get cluster members from each node to build actual connections
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
const nodeResponse = await window.demoApiClient.getClusterMembersFromNode(node.ip);
|
||||
if (nodeResponse && nodeResponse.members) {
|
||||
nodeConnections.set(node.ip, nodeResponse.members);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get cluster members from node ${node.ip}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Build links based on actual connections
|
||||
for (const [sourceIp, sourceMembers] of nodeConnections) {
|
||||
for (const targetMember of sourceMembers) {
|
||||
if (targetMember.ip && targetMember.ip !== sourceIp) {
|
||||
const existingLink = links.find(link =>
|
||||
(link.source === sourceIp && link.target === targetMember.ip) ||
|
||||
(link.source === targetMember.ip && link.target === sourceIp)
|
||||
);
|
||||
|
||||
if (!existingLink) {
|
||||
const sourceNode = nodes.find(n => n.id === sourceIp);
|
||||
const targetNode = nodes.find(n => n.id === targetMember.ip);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: sourceIp,
|
||||
target: targetMember.ip,
|
||||
latency: latency,
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
bidirectional: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no actual connections found, create a basic mesh
|
||||
if (links.length === 0) {
|
||||
console.log('No actual connections found, creating basic mesh');
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const sourceNode = nodes[i];
|
||||
const targetNode = nodes[j];
|
||||
|
||||
const estimatedLatency = this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: sourceNode.id,
|
||||
target: targetNode.id,
|
||||
latency: estimatedLatency,
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
bidirectional: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
estimateLatency(sourceNode, targetNode) {
|
||||
const baseLatency = 5;
|
||||
const randomVariation = Math.random() * 10;
|
||||
return Math.round(baseLatency + randomVariation);
|
||||
}
|
||||
|
||||
selectNode(nodeId) {
|
||||
this.selectedNode = nodeId;
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Demo Members Graph Component
|
||||
class DemoTopologyGraphComponent {
|
||||
constructor(container, viewModel) {
|
||||
this.container = container;
|
||||
this.viewModel = viewModel;
|
||||
this.svg = null;
|
||||
this.simulation = null;
|
||||
this.zoom = null;
|
||||
this.width = 800;
|
||||
this.height = 600;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('DemoTopologyGraphComponent: Initializing...');
|
||||
|
||||
// Set up the SVG container
|
||||
this.setupSVG();
|
||||
|
||||
// Initial data load
|
||||
await this.viewModel.updateNetworkTopology();
|
||||
}
|
||||
|
||||
setupSVG() {
|
||||
const container = this.findElement('#members-graph-container');
|
||||
if (!container) {
|
||||
console.error('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 #ddd')
|
||||
.style('background', '#f9f9f9');
|
||||
|
||||
// Add zoom behavior
|
||||
this.zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
this.svg.select('g').attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(this.zoom);
|
||||
|
||||
// Create main group for zoom
|
||||
this.svg.append('g');
|
||||
}
|
||||
|
||||
renderGraph() {
|
||||
const nodes = this.viewModel.nodes;
|
||||
const links = this.viewModel.links;
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
this.showNoData();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Rendering graph with', nodes.length, 'nodes and', links.length, 'links');
|
||||
|
||||
const svgGroup = this.svg.select('g');
|
||||
|
||||
// Clear existing elements
|
||||
svgGroup.selectAll('*').remove();
|
||||
|
||||
// Create arrow marker for links
|
||||
svgGroup.append('defs').selectAll('marker')
|
||||
.data(['end'])
|
||||
.enter().append('marker')
|
||||
.attr('id', 'arrowhead')
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 20)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', '#999');
|
||||
|
||||
// Create links with better styling
|
||||
const link = svgGroup.append('g')
|
||||
.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(2, Math.min(8, d.latency / 3)))
|
||||
.attr('marker-end', 'url(#arrowhead)');
|
||||
|
||||
// Create nodes
|
||||
const node = svgGroup.append('g')
|
||||
.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', '12px')
|
||||
.attr('fill', '#333')
|
||||
.attr('font-weight', '500');
|
||||
|
||||
// Add IP address labels
|
||||
node.append('text')
|
||||
.text(d => d.ip)
|
||||
.attr('x', 15)
|
||||
.attr('y', 20)
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#666');
|
||||
|
||||
// Add status labels
|
||||
node.append('text')
|
||||
.text(d => d.status)
|
||||
.attr('x', 15)
|
||||
.attr('y', 35)
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('font-weight', '600');
|
||||
|
||||
// Add latency labels on links with better positioning
|
||||
const linkLabels = svgGroup.append('g')
|
||||
.selectAll('text')
|
||||
.data(links)
|
||||
.enter().append('text')
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', '#333')
|
||||
.attr('font-weight', '500')
|
||||
.attr('text-anchor', 'middle')
|
||||
.text(d => `${d.latency}ms`);
|
||||
|
||||
// Add background for link labels
|
||||
const linkLabelBackgrounds = svgGroup.append('g')
|
||||
.selectAll('rect')
|
||||
.data(links)
|
||||
.enter().append('rect')
|
||||
.attr('width', d => `${d.latency}ms`.length * 6 + 4)
|
||||
.attr('height', 16)
|
||||
.attr('fill', '#fff')
|
||||
.attr('opacity', 0.8)
|
||||
.attr('rx', 3);
|
||||
|
||||
// Set up force simulation with better parameters
|
||||
this.simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-400))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force('collision', d3.forceCollide().radius(40));
|
||||
|
||||
// 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);
|
||||
|
||||
// Update link label backgrounds
|
||||
linkLabelBackgrounds
|
||||
.attr('x', d => (d.source.x + d.target.x) / 2 - (d.latency.toString().length * 6 + 4) / 2)
|
||||
.attr('y', d => (d.source.y + d.target.y) / 2 - 12);
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// 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(3, Math.min(10, d.latency / 2)))
|
||||
.attr('stroke-opacity', 1);
|
||||
});
|
||||
|
||||
link.on('mouseout', (event, d) => {
|
||||
d3.select(event.currentTarget)
|
||||
.attr('stroke-width', d => Math.max(2, Math.min(8, d.latency / 3)))
|
||||
.attr('stroke-opacity', 0.7);
|
||||
});
|
||||
|
||||
// Add legend
|
||||
this.addLegend(svgGroup);
|
||||
}
|
||||
|
||||
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 '#4CAF50';
|
||||
case 'INACTIVE':
|
||||
return '#FF9800';
|
||||
case 'DEAD':
|
||||
return '#F44336';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
}
|
||||
|
||||
getNodeColor(status) {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'ACTIVE':
|
||||
return '#4CAF50';
|
||||
case 'INACTIVE':
|
||||
return '#FF9800';
|
||||
case 'DEAD':
|
||||
return '#F44336';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
}
|
||||
|
||||
getLinkColor(latency) {
|
||||
if (latency <= 5) return '#4CAF50'; // Green for low latency
|
||||
if (latency <= 15) return '#FF9800'; // Orange for medium latency
|
||||
if (latency <= 30) return '#FF5722'; // Red-orange for high latency
|
||||
return '#F44336'; // Red for very high latency
|
||||
}
|
||||
|
||||
drag(simulation) {
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
return d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended);
|
||||
}
|
||||
|
||||
updateSelection(selectedNodeId) {
|
||||
// Update visual selection
|
||||
this.svg.selectAll('.node').select('circle')
|
||||
.attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2)
|
||||
.attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff');
|
||||
}
|
||||
|
||||
addLegend(svgGroup) {
|
||||
const legend = svgGroup.append('g')
|
||||
.attr('class', 'legend')
|
||||
.attr('transform', `translate(20, 20)`);
|
||||
|
||||
// Node status legend
|
||||
const nodeLegend = legend.append('g');
|
||||
nodeLegend.append('text')
|
||||
.text('Node Status:')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('font-size', '12px')
|
||||
.attr('font-weight', '600')
|
||||
.attr('fill', '#333');
|
||||
|
||||
const statuses = [
|
||||
{ status: 'ACTIVE', color: '#4CAF50', y: 20 },
|
||||
{ status: 'INACTIVE', color: '#FF9800', y: 40 },
|
||||
{ status: 'DEAD', color: '#F44336', 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', '10px')
|
||||
.attr('fill', '#333');
|
||||
});
|
||||
|
||||
// Link latency legend
|
||||
const linkLegend = legend.append('g')
|
||||
.attr('transform', 'translate(120, 0)');
|
||||
|
||||
linkLegend.append('text')
|
||||
.text('Link Latency:')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('font-size', '12px')
|
||||
.attr('font-weight', '600')
|
||||
.attr('fill', '#333');
|
||||
|
||||
const latencies = [
|
||||
{ range: '≤5ms', color: '#4CAF50', y: 20 },
|
||||
{ range: '6-15ms', color: '#FF9800', y: 40 },
|
||||
{ range: '16-30ms', color: '#FF5722', y: 60 },
|
||||
{ range: '>30ms', color: '#F44336', y: 80 }
|
||||
];
|
||||
|
||||
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', 3);
|
||||
|
||||
linkLegend.append('text')
|
||||
.text(item.range)
|
||||
.attr('x', 25)
|
||||
.attr('y', item.y + 4)
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#333');
|
||||
});
|
||||
}
|
||||
|
||||
findElement(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
showNoData() {
|
||||
const container = this.findElement('#members-graph-container');
|
||||
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation functions
|
||||
function showCluster() {
|
||||
document.querySelectorAll('.view-content').forEach(v => v.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('cluster-view').classList.add('active');
|
||||
document.querySelector('[onclick="showCluster()"]').classList.add('active');
|
||||
}
|
||||
|
||||
function showTopology() {
|
||||
document.querySelectorAll('.view-content').forEach(v => v.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('topology-view').classList.add('active');
|
||||
document.querySelector('[onclick="showTopology()"]').classList.add('active');
|
||||
}
|
||||
|
||||
function showFirmware() {
|
||||
document.querySelectorAll('.view-content').forEach(v => v.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('firmware-view').classList.add('active');
|
||||
document.querySelector('[onclick="showFirmware()"]').classList.add('active');
|
||||
}
|
||||
|
||||
function refreshTopologyView() {
|
||||
if (window.topologyGraphComponent) {
|
||||
window.topologyGraphComponent.viewModel.updateNetworkTopology();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the demo
|
||||
window.addEventListener('DOMContentLoaded', async function() {
|
||||
console.log('Demo Members View: Initializing...');
|
||||
|
||||
// Create view model and component
|
||||
const topologyViewModel = new DemoTopologyViewModel();
|
||||
const topologyGraphComponent = new DemoTopologyGraphComponent(
|
||||
document.getElementById('topology-graph-container'),
|
||||
topologyViewModel
|
||||
);
|
||||
|
||||
// Store globally for refresh function
|
||||
window.topologyGraphComponent = topologyGraphComponent;
|
||||
|
||||
// Initialize
|
||||
await topologyGraphComponent.initialize();
|
||||
|
||||
console.log('Demo Topology View: Initialization completed');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="./d3.v7.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -16,6 +17,7 @@
|
||||
</button>
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
@@ -55,7 +57,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div id="topology-view" class="view-content">
|
||||
<div id="topology-graph-container">
|
||||
<div class="loading">
|
||||
<div>Loading network topology...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<!--div class="firmware-header">
|
||||
<div class="firmware-header-left"></div>
|
||||
|
||||
@@ -2304,4 +2304,105 @@ p {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Topology View Styles */
|
||||
#topology-view {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 100vh; /* Ensure full viewport height */
|
||||
}
|
||||
|
||||
#topology-graph-container {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
min-height: 100%;
|
||||
height: 100vh; /* Use full viewport height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#topology-graph-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#topology-graph-container .loading,
|
||||
#topology-graph-container .error,
|
||||
#topology-graph-container .no-data {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
#topology-graph-container .error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
#topology-graph-container .no-data {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Node and link styles for the graph */
|
||||
.node circle {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.node text {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node:hover circle {
|
||||
stroke-width: 3;
|
||||
stroke: #60a5fa;
|
||||
}
|
||||
|
||||
/* Legend styles */
|
||||
.legend text {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.legend line {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Graph container enhancements */
|
||||
#members-graph-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#members-graph-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Loading and error states */
|
||||
.loading, .error, .no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.loading div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error div {
|
||||
color: #f87171;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-data div {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
296
public/test-members-debug.html
Normal file
296
public/test-members-debug.html
Normal file
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Members Component</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
|
||||
button { padding: 10px 20px; margin: 5px; background: #007bff; color: white; border: none; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
#graph-container { width: 600px; height: 400px; border: 1px solid #ccc; margin: 20px 0; }
|
||||
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
.info { background: #d1ecf1; color: #0c5460; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🐛 Debug Members Component</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Component Test</h3>
|
||||
<p>Testing if the TopologyGraphComponent can be created and initialized.</p>
|
||||
<button onclick="testComponentCreation()">Test Component Creation</button>
|
||||
<button onclick="testInitialization()">Test Initialization</button>
|
||||
<button onclick="testMount()">Test Mount</button>
|
||||
<button onclick="clearTest()">Clear Test</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Graph Container</h3>
|
||||
<div id="graph-container">
|
||||
<div style="text-align: center; padding: 50px; color: #666;">
|
||||
Graph will appear here after testing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let testComponent = null;
|
||||
|
||||
function showStatus(message, type = 'info') {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
|
||||
}
|
||||
|
||||
// Mock base Component class
|
||||
class MockComponent {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
this.container = container;
|
||||
this.viewModel = viewModel;
|
||||
this.eventBus = eventBus;
|
||||
this.isMounted = false;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('MockComponent: initialize called');
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
mount() {
|
||||
console.log('MockComponent: mount called');
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
console.log('MockComponent: setupEventListeners called');
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
console.log('MockComponent: setupViewModelListeners called');
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log('MockComponent: render called');
|
||||
}
|
||||
}
|
||||
|
||||
// Mock ViewModel
|
||||
class MockViewModel {
|
||||
constructor() {
|
||||
this._data = {
|
||||
nodes: [],
|
||||
links: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
get(property) {
|
||||
return this._data[property];
|
||||
}
|
||||
|
||||
set(property, value) {
|
||||
this._data[property] = value;
|
||||
}
|
||||
|
||||
subscribe(property, callback) {
|
||||
console.log(`MockViewModel: Subscribing to ${property}`);
|
||||
}
|
||||
|
||||
async updateNetworkTopology() {
|
||||
console.log('MockViewModel: updateNetworkTopology called');
|
||||
// Simulate some data
|
||||
this.set('nodes', [
|
||||
{ id: '1', hostname: 'Test Node 1', ip: '192.168.1.1', status: 'ACTIVE' },
|
||||
{ id: '2', hostname: 'Test Node 2', ip: '192.168.1.2', status: 'ACTIVE' }
|
||||
]);
|
||||
this.set('links', [
|
||||
{ source: '1', target: '2', latency: 5 }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Test TopologyGraphComponent (simplified version)
|
||||
class TestTopologyGraphComponent extends MockComponent {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
console.log('TestTopologyGraphComponent: Constructor called');
|
||||
this.svg = null;
|
||||
this.simulation = null;
|
||||
this.zoom = null;
|
||||
this.width = 600;
|
||||
this.height = 400;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
// Override mount to ensure proper initialization
|
||||
mount() {
|
||||
if (this.isMounted) return;
|
||||
|
||||
console.log('TestTopologyGraphComponent: Starting mount...');
|
||||
|
||||
// Call initialize if not already done
|
||||
if (!this.isInitialized) {
|
||||
console.log('TestTopologyGraphComponent: Initializing during mount...');
|
||||
this.initialize().then(() => {
|
||||
// Complete mount after initialization
|
||||
this.completeMount();
|
||||
}).catch(error => {
|
||||
console.error('TestTopologyGraphComponent: Initialization failed during mount:', error);
|
||||
// Still complete mount to prevent blocking
|
||||
this.completeMount();
|
||||
});
|
||||
} else {
|
||||
this.completeMount();
|
||||
}
|
||||
}
|
||||
|
||||
completeMount() {
|
||||
this.isMounted = true;
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.render();
|
||||
|
||||
console.log('TestTopologyGraphComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('TestTopologyGraphComponent: Initializing...');
|
||||
await super.initialize();
|
||||
|
||||
// Set up the SVG container
|
||||
this.setupSVG();
|
||||
|
||||
// Mark as initialized
|
||||
this.isInitialized = true;
|
||||
|
||||
console.log('TestTopologyGraphComponent: Initialization completed');
|
||||
}
|
||||
|
||||
setupSVG() {
|
||||
const container = document.getElementById('graph-container');
|
||||
if (!container) {
|
||||
console.error('TestTopologyGraphComponent: 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)
|
||||
.style('border', '1px solid #ddd')
|
||||
.style('background', '#f9f9f9');
|
||||
|
||||
this.svg.append('g');
|
||||
|
||||
console.log('TestTopologyGraphComponent: SVG setup completed');
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log('TestTopologyGraphComponent: render called');
|
||||
// Simple render for testing
|
||||
if (this.svg) {
|
||||
const svgGroup = this.svg.select('g');
|
||||
svgGroup.append('text')
|
||||
.attr('x', 300)
|
||||
.attr('y', 200)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '16px')
|
||||
.text('Component Rendered Successfully!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function testComponentCreation() {
|
||||
try {
|
||||
showStatus('🔄 Testing component creation...', 'info');
|
||||
|
||||
const mockViewModel = new MockViewModel();
|
||||
const mockEventBus = {};
|
||||
|
||||
testComponent = new TestTopologyGraphComponent(
|
||||
document.getElementById('graph-container'),
|
||||
mockViewModel,
|
||||
mockEventBus
|
||||
);
|
||||
|
||||
showStatus('✅ Component created successfully', 'success');
|
||||
console.log('Component created:', testComponent);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Component creation failed:', error);
|
||||
showStatus(`❌ Component creation failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testInitialization() {
|
||||
if (!testComponent) {
|
||||
showStatus('❌ No component created. Run component creation test first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus('🔄 Testing initialization...', 'info');
|
||||
|
||||
testComponent.initialize().then(() => {
|
||||
showStatus('✅ Component initialized successfully', 'success');
|
||||
}).catch(error => {
|
||||
console.error('Initialization failed:', error);
|
||||
showStatus(`❌ Initialization failed: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Initialization test failed:', error);
|
||||
showStatus(`❌ Initialization test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testMount() {
|
||||
if (!testComponent) {
|
||||
showStatus('❌ No component created. Run component creation test first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus('🔄 Testing mount...', 'info');
|
||||
|
||||
testComponent.mount();
|
||||
|
||||
showStatus('✅ Component mounted successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mount test failed:', error);
|
||||
showStatus(`❌ Mount test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearTest() {
|
||||
if (testComponent) {
|
||||
testComponent = null;
|
||||
}
|
||||
document.getElementById('graph-container').innerHTML =
|
||||
'<div style="text-align: center; padding: 50px; color: #666;">Graph will appear here after testing</div>';
|
||||
document.getElementById('status').innerHTML = '';
|
||||
showStatus('🧹 Test cleared', 'info');
|
||||
}
|
||||
|
||||
// Auto-test on load
|
||||
window.addEventListener('load', () => {
|
||||
showStatus('🚀 Debug page loaded. Click "Test Component Creation" to begin.', 'info');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
296
public/test-topology-debug.html
Normal file
296
public/test-topology-debug.html
Normal file
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Members Component</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
|
||||
button { padding: 10px 20px; margin: 5px; background: #007bff; color: white; border: none; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
#graph-container { width: 600px; height: 400px; border: 1px solid #ccc; margin: 20px 0; }
|
||||
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
.info { background: #d1ecf1; color: #0c5460; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🐛 Debug Members Component</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Component Test</h3>
|
||||
<p>Testing if the TopologyGraphComponent can be created and initialized.</p>
|
||||
<button onclick="testComponentCreation()">Test Component Creation</button>
|
||||
<button onclick="testInitialization()">Test Initialization</button>
|
||||
<button onclick="testMount()">Test Mount</button>
|
||||
<button onclick="clearTest()">Clear Test</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Graph Container</h3>
|
||||
<div id="graph-container">
|
||||
<div style="text-align: center; padding: 50px; color: #666;">
|
||||
Graph will appear here after testing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let testComponent = null;
|
||||
|
||||
function showStatus(message, type = 'info') {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
|
||||
}
|
||||
|
||||
// Mock base Component class
|
||||
class MockComponent {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
this.container = container;
|
||||
this.viewModel = viewModel;
|
||||
this.eventBus = eventBus;
|
||||
this.isMounted = false;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('MockComponent: initialize called');
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
mount() {
|
||||
console.log('MockComponent: mount called');
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
console.log('MockComponent: setupEventListeners called');
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
console.log('MockComponent: setupViewModelListeners called');
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log('MockComponent: render called');
|
||||
}
|
||||
}
|
||||
|
||||
// Mock ViewModel
|
||||
class MockViewModel {
|
||||
constructor() {
|
||||
this._data = {
|
||||
nodes: [],
|
||||
links: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
get(property) {
|
||||
return this._data[property];
|
||||
}
|
||||
|
||||
set(property, value) {
|
||||
this._data[property] = value;
|
||||
}
|
||||
|
||||
subscribe(property, callback) {
|
||||
console.log(`MockViewModel: Subscribing to ${property}`);
|
||||
}
|
||||
|
||||
async updateNetworkTopology() {
|
||||
console.log('MockViewModel: updateNetworkTopology called');
|
||||
// Simulate some data
|
||||
this.set('nodes', [
|
||||
{ id: '1', hostname: 'Test Node 1', ip: '192.168.1.1', status: 'ACTIVE' },
|
||||
{ id: '2', hostname: 'Test Node 2', ip: '192.168.1.2', status: 'ACTIVE' }
|
||||
]);
|
||||
this.set('links', [
|
||||
{ source: '1', target: '2', latency: 5 }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Test TopologyGraphComponent (simplified version)
|
||||
class TestTopologyGraphComponent extends MockComponent {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
console.log('TestTopologyGraphComponent: Constructor called');
|
||||
this.svg = null;
|
||||
this.simulation = null;
|
||||
this.zoom = null;
|
||||
this.width = 600;
|
||||
this.height = 400;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
// Override mount to ensure proper initialization
|
||||
mount() {
|
||||
if (this.isMounted) return;
|
||||
|
||||
console.log('TestTopologyGraphComponent: Starting mount...');
|
||||
|
||||
// Call initialize if not already done
|
||||
if (!this.isInitialized) {
|
||||
console.log('TestTopologyGraphComponent: Initializing during mount...');
|
||||
this.initialize().then(() => {
|
||||
// Complete mount after initialization
|
||||
this.completeMount();
|
||||
}).catch(error => {
|
||||
console.error('TestTopologyGraphComponent: Initialization failed during mount:', error);
|
||||
// Still complete mount to prevent blocking
|
||||
this.completeMount();
|
||||
});
|
||||
} else {
|
||||
this.completeMount();
|
||||
}
|
||||
}
|
||||
|
||||
completeMount() {
|
||||
this.isMounted = true;
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.render();
|
||||
|
||||
console.log('TestTopologyGraphComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('TestTopologyGraphComponent: Initializing...');
|
||||
await super.initialize();
|
||||
|
||||
// Set up the SVG container
|
||||
this.setupSVG();
|
||||
|
||||
// Mark as initialized
|
||||
this.isInitialized = true;
|
||||
|
||||
console.log('TestTopologyGraphComponent: Initialization completed');
|
||||
}
|
||||
|
||||
setupSVG() {
|
||||
const container = document.getElementById('graph-container');
|
||||
if (!container) {
|
||||
console.error('TestTopologyGraphComponent: 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)
|
||||
.style('border', '1px solid #ddd')
|
||||
.style('background', '#f9f9f9');
|
||||
|
||||
this.svg.append('g');
|
||||
|
||||
console.log('TestTopologyGraphComponent: SVG setup completed');
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log('TestTopologyGraphComponent: render called');
|
||||
// Simple render for testing
|
||||
if (this.svg) {
|
||||
const svgGroup = this.svg.select('g');
|
||||
svgGroup.append('text')
|
||||
.attr('x', 300)
|
||||
.attr('y', 200)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '16px')
|
||||
.text('Component Rendered Successfully!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function testComponentCreation() {
|
||||
try {
|
||||
showStatus('🔄 Testing component creation...', 'info');
|
||||
|
||||
const mockViewModel = new MockViewModel();
|
||||
const mockEventBus = {};
|
||||
|
||||
testComponent = new TestTopologyGraphComponent(
|
||||
document.getElementById('graph-container'),
|
||||
mockViewModel,
|
||||
mockEventBus
|
||||
);
|
||||
|
||||
showStatus('✅ Component created successfully', 'success');
|
||||
console.log('Component created:', testComponent);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Component creation failed:', error);
|
||||
showStatus(`❌ Component creation failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testInitialization() {
|
||||
if (!testComponent) {
|
||||
showStatus('❌ No component created. Run component creation test first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus('🔄 Testing initialization...', 'info');
|
||||
|
||||
testComponent.initialize().then(() => {
|
||||
showStatus('✅ Component initialized successfully', 'success');
|
||||
}).catch(error => {
|
||||
console.error('Initialization failed:', error);
|
||||
showStatus(`❌ Initialization failed: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Initialization test failed:', error);
|
||||
showStatus(`❌ Initialization test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testMount() {
|
||||
if (!testComponent) {
|
||||
showStatus('❌ No component created. Run component creation test first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus('🔄 Testing mount...', 'info');
|
||||
|
||||
testComponent.mount();
|
||||
|
||||
showStatus('✅ Component mounted successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mount test failed:', error);
|
||||
showStatus(`❌ Mount test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearTest() {
|
||||
if (testComponent) {
|
||||
testComponent = null;
|
||||
}
|
||||
document.getElementById('graph-container').innerHTML =
|
||||
'<div style="text-align: center; padding: 50px; color: #666;">Graph will appear here after testing</div>';
|
||||
document.getElementById('status').innerHTML = '';
|
||||
showStatus('🧹 Test cleared', 'info');
|
||||
}
|
||||
|
||||
// Auto-test on load
|
||||
window.addEventListener('load', () => {
|
||||
showStatus('🚀 Debug page loaded. Click "Test Component Creation" to begin.', 'info');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
286
public/test-topology-fix.html
Normal file
286
public/test-topology-fix.html
Normal file
@@ -0,0 +1,286 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Members View Fix</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
|
||||
button { padding: 10px 20px; margin: 5px; background: #007bff; color: white; border: none; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
#graph-container { width: 600px; height: 400px; border: 1px solid #ccc; margin: 20px 0; }
|
||||
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 Test Members View Fix</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Initialization Test</h3>
|
||||
<p>Testing the fixed initialization order for the Members view component.</p>
|
||||
<button onclick="testInitialization()">Test Initialization</button>
|
||||
<button onclick="testDataUpdate()">Test Data Update</button>
|
||||
<button onclick="clearTest()">Clear Test</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Graph Container</h3>
|
||||
<div id="graph-container">
|
||||
<div style="text-align: center; padding: 50px; color: #666;">
|
||||
Graph will appear here after initialization
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let testComponent = null;
|
||||
let testViewModel = null;
|
||||
|
||||
function showStatus(message, type = 'success') {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
|
||||
}
|
||||
|
||||
// Mock ViewModel for testing
|
||||
class TestViewModel {
|
||||
constructor() {
|
||||
this._data = {
|
||||
nodes: [],
|
||||
links: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
this._listeners = new Map();
|
||||
}
|
||||
|
||||
get(property) {
|
||||
return this._data[property];
|
||||
}
|
||||
|
||||
set(property, value) {
|
||||
this._data[property] = value;
|
||||
this._notifyListeners(property, value);
|
||||
}
|
||||
|
||||
subscribe(property, callback) {
|
||||
if (!this._listeners.has(property)) {
|
||||
this._listeners.set(property, []);
|
||||
}
|
||||
this._listeners.get(property).push(callback);
|
||||
}
|
||||
|
||||
_notifyListeners(property, value) {
|
||||
if (this._listeners.has(property)) {
|
||||
this._listeners.get(property).forEach(callback => {
|
||||
try {
|
||||
callback(value);
|
||||
} catch (error) {
|
||||
console.error(`Error in property listener for ${property}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test Component for testing
|
||||
class TestMembersComponent {
|
||||
constructor(container, viewModel) {
|
||||
this.container = container;
|
||||
this.viewModel = viewModel;
|
||||
this.svg = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('TestMembersComponent: Initializing...');
|
||||
|
||||
// Simulate async initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Set up SVG
|
||||
this.setupSVG();
|
||||
|
||||
// Mark as initialized
|
||||
this.isInitialized = true;
|
||||
|
||||
// Set up listeners AFTER initialization
|
||||
this.viewModel.subscribe('nodes', this.renderGraph.bind(this));
|
||||
this.viewModel.subscribe('links', this.renderGraph.bind(this));
|
||||
|
||||
console.log('TestMembersComponent: Initialization completed');
|
||||
showStatus('✅ Component initialized successfully', 'success');
|
||||
}
|
||||
|
||||
setupSVG() {
|
||||
const container = document.getElementById('graph-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
this.svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('width', 600)
|
||||
.attr('height', 400)
|
||||
.style('border', '1px solid #ddd')
|
||||
.style('background', '#f9f9f9');
|
||||
|
||||
this.svg.append('g');
|
||||
console.log('TestMembersComponent: SVG setup completed');
|
||||
}
|
||||
|
||||
renderGraph() {
|
||||
try {
|
||||
// Check if component is initialized
|
||||
if (!this.isInitialized) {
|
||||
console.log('TestMembersComponent: Component not yet initialized, skipping render');
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = this.viewModel.get('nodes');
|
||||
const links = this.viewModel.get('links');
|
||||
|
||||
// Check if SVG is initialized
|
||||
if (!this.svg) {
|
||||
console.log('TestMembersComponent: SVG not initialized yet, setting up SVG first');
|
||||
this.setupSVG();
|
||||
}
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
this.showNoData();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('TestMembersComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links');
|
||||
|
||||
const svgGroup = this.svg.select('g');
|
||||
if (!svgGroup || svgGroup.empty()) {
|
||||
console.error('TestMembersComponent: SVG group not found, cannot render graph');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing elements
|
||||
svgGroup.selectAll('*').remove();
|
||||
|
||||
// Create simple nodes
|
||||
const node = svgGroup.append('g')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.enter().append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', (d, i) => `translate(${100 + i * 150}, 200)`);
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', 20)
|
||||
.attr('fill', '#4CAF50');
|
||||
|
||||
node.append('text')
|
||||
.text(d => d.name)
|
||||
.attr('x', 0)
|
||||
.attr('y', 30)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '12px');
|
||||
|
||||
// Create simple links
|
||||
if (links.length > 0) {
|
||||
const link = svgGroup.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter().append('line')
|
||||
.attr('stroke', '#999')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('x1', (d, i) => 100 + i * 150)
|
||||
.attr('y1', 200)
|
||||
.attr('x2', (d, i) => 100 + (i + 1) * 150)
|
||||
.attr('y2', 200);
|
||||
}
|
||||
|
||||
showStatus('✅ Graph rendered successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to render graph:', error);
|
||||
showStatus(`❌ Graph rendering failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
showNoData() {
|
||||
const container = document.getElementById('graph-container');
|
||||
container.innerHTML = '<div style="text-align: center; padding: 50px; color: #666;">No data available</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function testInitialization() {
|
||||
try {
|
||||
showStatus('🔄 Testing initialization...', 'success');
|
||||
|
||||
// Create test view model and component
|
||||
testViewModel = new TestViewModel();
|
||||
testComponent = new TestMembersComponent(
|
||||
document.getElementById('graph-container'),
|
||||
testViewModel
|
||||
);
|
||||
|
||||
// Initialize component
|
||||
await testComponent.initialize();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Initialization test failed:', error);
|
||||
showStatus(`❌ Initialization test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testDataUpdate() {
|
||||
if (!testComponent || !testComponent.isInitialized) {
|
||||
showStatus('❌ Component not initialized. Run initialization test first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus('🔄 Testing data update...', 'success');
|
||||
|
||||
// Update with test data
|
||||
const testNodes = [
|
||||
{ name: 'Node 1', id: '1' },
|
||||
{ name: 'Node 2', id: '2' },
|
||||
{ name: 'Node 3', id: '3' }
|
||||
];
|
||||
|
||||
const testLinks = [
|
||||
{ source: '1', target: '2' },
|
||||
{ source: '2', target: '3' }
|
||||
];
|
||||
|
||||
testViewModel.set('nodes', testNodes);
|
||||
testViewModel.set('links', testLinks);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Data update test failed:', error);
|
||||
showStatus(`❌ Data update test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearTest() {
|
||||
if (testComponent) {
|
||||
testComponent = null;
|
||||
}
|
||||
if (testViewModel) {
|
||||
testViewModel = null;
|
||||
}
|
||||
document.getElementById('graph-container').innerHTML =
|
||||
'<div style="text-align: center; padding: 50px; color: #666;">Graph will appear here after initialization</div>';
|
||||
document.getElementById('status').innerHTML = '';
|
||||
showStatus('🧹 Test cleared', 'success');
|
||||
}
|
||||
|
||||
// Auto-test on load
|
||||
window.addEventListener('load', () => {
|
||||
showStatus('🚀 Test page loaded. Click "Test Initialization" to begin.', 'success');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
254
public/test-topology-view.html
Normal file
254
public/test-topology-view.html
Normal file
@@ -0,0 +1,254 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Members View</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.test-section h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
button {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
#graph-container {
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
border: 1px solid #ddd;
|
||||
background: #f9f9f9;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 Test Members View</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>D3.js Integration Test</h3>
|
||||
<p>Testing D3.js library integration and basic force-directed graph functionality.</p>
|
||||
<button onclick="testD3Integration()">Test D3.js Integration</button>
|
||||
<button onclick="testForceGraph()">Test Force Graph</button>
|
||||
<button onclick="clearGraph()">Clear Graph</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Graph Visualization</h3>
|
||||
<div id="graph-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let svg = null;
|
||||
let simulation = null;
|
||||
|
||||
function showStatus(message, type = 'info') {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
|
||||
}
|
||||
|
||||
function testD3Integration() {
|
||||
try {
|
||||
if (typeof d3 === 'undefined') {
|
||||
throw new Error('D3.js is not loaded');
|
||||
}
|
||||
|
||||
const version = d3.version;
|
||||
showStatus(`✅ D3.js v${version} loaded successfully!`, 'success');
|
||||
|
||||
// Test basic D3 functionality
|
||||
const testData = [1, 2, 3, 4, 5];
|
||||
const testSelection = d3.select('#graph-container');
|
||||
testSelection.append('div')
|
||||
.attr('class', 'test-div')
|
||||
.text('D3 selection test');
|
||||
|
||||
showStatus('✅ D3.js basic functionality working!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
showStatus(`❌ D3.js test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testForceGraph() {
|
||||
try {
|
||||
if (!svg) {
|
||||
setupSVG();
|
||||
}
|
||||
|
||||
// Create sample data
|
||||
const nodes = [
|
||||
{ id: 'node1', name: 'Node 1', status: 'ACTIVE' },
|
||||
{ id: 'node2', name: 'Node 2', status: 'ACTIVE' },
|
||||
{ id: 'node3', name: 'Node 3', status: 'INACTIVE' },
|
||||
{ id: 'node4', name: 'Node 4', status: 'ACTIVE' }
|
||||
];
|
||||
|
||||
const links = [
|
||||
{ source: 'node1', target: 'node2', latency: 5 },
|
||||
{ source: 'node1', target: 'node3', latency: 15 },
|
||||
{ source: 'node2', target: 'node4', latency: 8 },
|
||||
{ source: 'node3', target: 'node4', latency: 25 }
|
||||
];
|
||||
|
||||
renderGraph(nodes, links);
|
||||
showStatus('✅ Force-directed graph created successfully!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
showStatus(`❌ Force graph test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function setupSVG() {
|
||||
const container = document.getElementById('graph-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('width', 800)
|
||||
.attr('height', 600)
|
||||
.style('border', '1px solid #ddd')
|
||||
.style('background', '#f9f9f9');
|
||||
}
|
||||
|
||||
function renderGraph(nodes, links) {
|
||||
if (!svg) return;
|
||||
|
||||
const svgGroup = svg.append('g');
|
||||
|
||||
// Create links
|
||||
const link = svgGroup.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter().append('line')
|
||||
.attr('stroke', '#999')
|
||||
.attr('stroke-opacity', 0.6)
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Create nodes
|
||||
const node = svgGroup.append('g')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.enter().append('g')
|
||||
.attr('class', 'node')
|
||||
.call(d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended));
|
||||
|
||||
// Add circles to nodes
|
||||
node.append('circle')
|
||||
.attr('r', 8)
|
||||
.attr('fill', d => getNodeColor(d.status));
|
||||
|
||||
// Add labels
|
||||
node.append('text')
|
||||
.text(d => d.name)
|
||||
.attr('x', 12)
|
||||
.attr('y', 4)
|
||||
.attr('font-size', '12px');
|
||||
|
||||
// Set up force simulation
|
||||
simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(400, 300));
|
||||
|
||||
// Update positions on simulation tick
|
||||
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);
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeColor(status) {
|
||||
switch (status) {
|
||||
case 'ACTIVE': return '#4CAF50';
|
||||
case 'INACTIVE': return '#FF9800';
|
||||
default: return '#9E9E9E';
|
||||
}
|
||||
}
|
||||
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
function clearGraph() {
|
||||
if (svg) {
|
||||
svg.selectAll('*').remove();
|
||||
svg = null;
|
||||
}
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
simulation = null;
|
||||
}
|
||||
showStatus('Graph cleared', 'info');
|
||||
}
|
||||
|
||||
// Auto-test on load
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
testD3Integration();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -473,4 +473,160 @@ class NavigationViewModel extends ViewModel {
|
||||
getActiveView() {
|
||||
return this.get('activeView');
|
||||
}
|
||||
}
|
||||
|
||||
// Topology View Model for network topology visualization
|
||||
class TopologyViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
nodes: [],
|
||||
links: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null,
|
||||
selectedNode: null
|
||||
});
|
||||
}
|
||||
|
||||
// Update network topology data
|
||||
async updateNetworkTopology() {
|
||||
try {
|
||||
console.log('TopologyViewModel: updateNetworkTopology called');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
|
||||
// Get cluster members from the primary node
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
console.log('TopologyViewModel: Got cluster members response:', response);
|
||||
|
||||
const members = response.members || [];
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
||||
|
||||
this.batchUpdate({
|
||||
nodes: nodes,
|
||||
links: links,
|
||||
lastUpdateTime: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('TopologyViewModel: Failed to fetch network topology:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
console.log('TopologyViewModel: updateNetworkTopology completed');
|
||||
}
|
||||
}
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
async buildEnhancedGraphData(members) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const nodeConnections = new Map();
|
||||
|
||||
// Create nodes from members
|
||||
members.forEach((member, index) => {
|
||||
if (member && member.ip) {
|
||||
nodes.push({
|
||||
id: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
ip: member.ip,
|
||||
status: member.status || 'UNKNOWN',
|
||||
latency: member.latency || 0,
|
||||
resources: member.resources || {},
|
||||
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
||||
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Try to get cluster members from each node to build actual connections
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
const nodeResponse = await window.apiClient.getClusterMembersFromNode(node.ip);
|
||||
if (nodeResponse && nodeResponse.members) {
|
||||
nodeConnections.set(node.ip, nodeResponse.members);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get cluster members from node ${node.ip}:`, error);
|
||||
// Continue with other nodes
|
||||
}
|
||||
}
|
||||
|
||||
// Build links based on actual connections
|
||||
for (const [sourceIp, sourceMembers] of nodeConnections) {
|
||||
for (const targetMember of sourceMembers) {
|
||||
if (targetMember.ip && targetMember.ip !== sourceIp) {
|
||||
// Check if we already have this link
|
||||
const existingLink = links.find(link =>
|
||||
(link.source === sourceIp && link.target === targetMember.ip) ||
|
||||
(link.source === targetMember.ip && link.target === sourceIp)
|
||||
);
|
||||
|
||||
if (!existingLink) {
|
||||
const sourceNode = nodes.find(n => n.id === sourceIp);
|
||||
const targetNode = nodes.find(n => n.id === targetMember.ip);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: sourceIp,
|
||||
target: targetMember.ip,
|
||||
latency: latency,
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
bidirectional: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no actual connections found, create a basic mesh
|
||||
if (links.length === 0) {
|
||||
console.log('TopologyViewModel: No actual connections found, creating basic mesh');
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const sourceNode = nodes[i];
|
||||
const targetNode = nodes[j];
|
||||
|
||||
const estimatedLatency = this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: sourceNode.id,
|
||||
target: targetNode.id,
|
||||
latency: estimatedLatency,
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
bidirectional: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
// Estimate latency between two nodes
|
||||
estimateLatency(sourceNode, targetNode) {
|
||||
// Simple estimation - in a real implementation, you'd get actual measurements
|
||||
const baseLatency = 5; // Base latency in ms
|
||||
const randomVariation = Math.random() * 10; // Random variation
|
||||
return Math.round(baseLatency + randomVariation);
|
||||
}
|
||||
|
||||
// Select a node in the graph
|
||||
selectNode(nodeId) {
|
||||
this.set('selectedNode', nodeId);
|
||||
}
|
||||
|
||||
// Clear node selection
|
||||
clearSelection() {
|
||||
this.set('selectedNode', null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user