diff --git a/README.md b/README.md index 1bb8e91..b481c32 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ Zero-configuration web interface for monitoring and managing SPORE embedded syst ## Screenshots ### Cluster - -### Capabilities - + +### Topology + ### Firmware  diff --git a/assets/capabilities.png b/assets/capabilities.png deleted file mode 100644 index 7692eb1..0000000 Binary files a/assets/capabilities.png and /dev/null differ diff --git a/assets/cluster.png b/assets/cluster.png index d5bafbe..a416975 100644 Binary files a/assets/cluster.png and b/assets/cluster.png differ diff --git a/assets/topology.png b/assets/topology.png new file mode 100644 index 0000000..716d1e7 Binary files /dev/null and b/assets/topology.png differ diff --git a/docs/TOPOLOGY_VIEW.md b/docs/TOPOLOGY_VIEW.md new file mode 100644 index 0000000..f615b90 --- /dev/null +++ b/docs/TOPOLOGY_VIEW.md @@ -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 \ No newline at end of file diff --git a/public/api-client.js b/public/api-client.js index ad9bbc2..d149570 100644 --- a/public/api-client.js +++ b/public/api-client.js @@ -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' }); diff --git a/public/app.js b/public/app.js index ad3e761..35f8dc7 100644 --- a/public/app.js +++ b/public/app.js @@ -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'); diff --git a/public/components.js b/public/components.js index 6dcd737..cc73745 100644 --- a/public/components.js +++ b/public/components.js @@ -2267,4 +2267,576 @@ class ClusterStatusComponent extends Component { this.container.classList.add(statusClass); } } -} \ No newline at end of file +} + +// 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 = '
0?d[i-1]:l,v.x1=i
0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e=i)&&(e=i)}return e}function tt(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e =0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=un);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o >1)+h+t+M+S.slice(A);break;default:t=S+h+t+M}return u(t)}return y=void 0===y?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Jc(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3))),i=Math.pow(10,-r),o=uf[8+r/3];return function(t){return e(i*t)+o}}}}function ff(n){return of=cf(n),t.format=of.format,t.formatPrefix=of.formatPrefix,of}function sf(t){return Math.max(0,-Zc(Math.abs(t)))}function lf(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3)))-Zc(Math.abs(t)))}function hf(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,Zc(n)-Zc(t))+1}t.format=void 0,t.formatPrefix=void 0,ff({thousands:",",grouping:[3],currency:["$",""]});var df=1e-6,pf=1e-12,gf=Math.PI,yf=gf/2,vf=gf/4,_f=2*gf,bf=180/gf,mf=gf/180,xf=Math.abs,wf=Math.atan,Mf=Math.atan2,Tf=Math.cos,Af=Math.ceil,Sf=Math.exp,Ef=Math.hypot,Nf=Math.log,kf=Math.pow,Cf=Math.sin,Pf=Math.sign||function(t){return t>0?1:t<0?-1:0},zf=Math.sqrt,$f=Math.tan;function Df(t){return t>1?0:t<-1?gf:Math.acos(t)}function Rf(t){return t>1?yf:t<-1?-yf:Math.asin(t)}function Ff(t){return(t=Cf(t/2))*t}function qf(){}function Uf(t,n){t&&Of.hasOwnProperty(t.type)&&Of[t.type](t,n)}var If={Feature:function(t,n){Uf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=Tf(n=(n*=mf)/2+vf),a=Cf(n),u=Vf*a,c=Gf*o+u*Tf(i),f=u*r*Cf(i);as.add(Mf(f,c)),Xf=t,Gf=o,Vf=a}function ds(t){return[Mf(t[1],t[0]),Rf(t[2])]}function ps(t){var n=t[0],e=t[1],r=Tf(e);return[r*Tf(n),r*Cf(n),Cf(e)]}function gs(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function ys(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function vs(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function _s(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function bs(t){var n=zf(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var ms,xs,ws,Ms,Ts,As,Ss,Es,Ns,ks,Cs,Ps,zs,$s,Ds,Rs,Fs={point:qs,lineStart:Is,lineEnd:Os,polygonStart:function(){Fs.point=Bs,Fs.lineStart=Ys,Fs.lineEnd=Ls,rs=new T,cs.polygonStart()},polygonEnd:function(){cs.polygonEnd(),Fs.point=qs,Fs.lineStart=Is,Fs.lineEnd=Os,as<0?(Wf=-(Kf=180),Zf=-(Qf=90)):rs>df?Qf=90:rs<-df&&(Zf=-90),os[0]=Wf,os[1]=Kf},sphere:function(){Wf=-(Kf=180),Zf=-(Qf=90)}};function qs(t,n){is.push(os=[Wf=t,Kf=t]),n t.r&&(t.r=t[n].r)}function c(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r >>1;f[g] This is a demo of the cluster view. Switch to Members to see the network topology visualization. This is a demo of the firmware view. Switch to Members to see the network topology visualization.1;)i-=2;for(let t=2;t0){if(n>=this.ymax)return null;(i=(this.ymax-n)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/e)this.xmax?2:0)|(n9999?"+"+Ku(n,6):Ku(n,4))+"-"+Ku(t.getUTCMonth()+1,2)+"-"+Ku(t.getUTCDate(),2)+(o?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"."+Ku(o,3)+"Z":i?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"Z":r||e?"T"+Ku(e,2)+":"+Ku(r,2)+"Z":"")}function Ju(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return Hu;if(f)return f=!1,ju;var n,r,i=a;if(t.charCodeAt(i)===Xu){for(;a++=v)<<1|t>=y)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,g.data),b=n-+this._y.call(null,g.data),m=_*_+b*b;if(m=u)){(t.data!==n||t.next)&&(0===l&&(p+=(l=Uc(e))*l),0===h&&(p+=(h=Uc(e))*h),p(t=(Lc*t+jc)%Hc)/Hc}();function l(){h(),f.call("tick",n),e1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=qc(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ejs(r[0],r[1])&&(r[1]=i[1]),js(i[0],r[1])>js(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=js(r[1],i[0]))>a&&(a=u,Wf=i[0],Kf=r[1])}return is=os=null,Wf===1/0||Zf===1/0?[[NaN,NaN],[NaN,NaN]]:[[Wf,Zf],[Kf,Qf]]},t.geoCentroid=function(t){ms=xs=ws=Ms=Ts=As=Ss=Es=0,Ns=new T,ks=new T,Cs=new T,Lf(t,Gs);var n=+Ns,e=+ks,r=+Cs,i=Ef(n,e,r);return i 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) };
+ }
+};
\ No newline at end of file
diff --git a/public/demo-topology-view.html b/public/demo-topology-view.html
new file mode 100644
index 0000000..13e4b22
--- /dev/null
+++ b/public/demo-topology-view.html
@@ -0,0 +1,641 @@
+
+
+
+
+
+ ๐ Cluster View (Demo)
+ ๐ Network Topology (Demo)
+
+ ๐ฆ Firmware View (Demo)
+