feat: add topology view

This commit is contained in:
2025-08-30 13:06:44 +02:00
parent c1b92b3fef
commit f28b4f8797
18 changed files with 2903 additions and 6 deletions

View File

@@ -11,9 +11,9 @@ Zero-configuration web interface for monitoring and managing SPORE embedded syst
## Screenshots
### Cluster
![UI](./assets/cluster.png)
### Capabilities
![UI](./assets/capabilities.png)
![UI](./assets/cluster.png)
### Topology
![UI](./assets/topology.png)
### Firmware
![UI](./assets/firmware.png)

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

146
docs/TOPOLOGY_VIEW.md Normal file
View 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

View File

@@ -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' });

View File

@@ -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');

View File

@@ -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

File diff suppressed because one or more lines are too long

View 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) };
}
};

View 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>

View File

@@ -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>

View File

@@ -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;
}

View 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>

View 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>

View 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>

View 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>

View File

@@ -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);
}
}