Compare commits
1 Commits
b4bd459d27
...
fecbc1e73d
| Author | SHA1 | Date | |
|---|---|---|---|
| fecbc1e73d |
@@ -1,248 +0,0 @@
|
||||
# Topology Component WebSocket Integration
|
||||
|
||||
## Summary
|
||||
Enhanced the topology graph component to support real-time node additions and removals via WebSocket connections. The topology view now automatically updates when nodes join or leave the cluster without requiring manual refresh. Existing nodes update their properties (status, labels) smoothly in place without being removed and re-added.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. TopologyViewModel (`spore-ui/public/scripts/view-models.js`)
|
||||
|
||||
Added `setupWebSocketListeners()` method to the TopologyViewModel class:
|
||||
|
||||
- **Listens to `clusterUpdate` events**: When cluster membership changes, the topology graph is automatically rebuilt with the new node data
|
||||
- **Listens to `nodeDiscovery` events**: When a new node is discovered or becomes stale, triggers a topology update
|
||||
- **Listens to connection status**: Automatically refreshes topology when WebSocket reconnects
|
||||
- **Async graph updates**: Rebuilds graph data asynchronously from WebSocket data to avoid blocking the UI
|
||||
|
||||
Enhanced `buildEnhancedGraphData()` method to preserve node state:
|
||||
|
||||
- **Position preservation**: Existing nodes retain their x, y coordinates across updates
|
||||
- **Velocity preservation**: D3 simulation velocity (vx, vy) is maintained for smooth physics
|
||||
- **Fixed position preservation**: Manually dragged nodes (fx, fy) stay in place
|
||||
- **New nodes only**: Only newly discovered nodes get random initial positions
|
||||
- **Result**: Nodes no longer "jump" or get removed/re-added when their properties update
|
||||
|
||||
### 2. TopologyGraphComponent (`spore-ui/public/scripts/components/TopologyGraphComponent.js`)
|
||||
|
||||
#### Added WebSocket Setup
|
||||
- Added `setupWebSocketListeners()` method that calls the view model's WebSocket setup during component initialization
|
||||
- Integrated into the `initialize()` lifecycle method
|
||||
|
||||
#### Improved Dynamic Updates (D3.js Enter/Exit Pattern)
|
||||
Refactored the graph rendering to use D3's data binding patterns for smooth transitions:
|
||||
|
||||
- **`updateLinks()`**: Uses enter/exit pattern to add/remove links with fade transitions
|
||||
- **`updateNodes()`**: Uses enter/exit pattern to add/remove nodes with fade transitions
|
||||
- New nodes fade in (300ms transition)
|
||||
- Removed nodes fade out (300ms transition)
|
||||
- Existing nodes smoothly update their properties
|
||||
- **`updateLinkLabels()`**: Dynamically updates link latency labels
|
||||
- **`updateSimulation()`**: Handles D3 force simulation updates
|
||||
- Creates new simulation on first render
|
||||
- Updates existing simulation with new node/link data on subsequent renders
|
||||
- Maintains smooth physics-based layout
|
||||
- **`addLegend()`**: Fixed to prevent duplicate legend creation
|
||||
|
||||
#### Key Improvements
|
||||
- **Incremental updates**: Instead of recreating the entire graph, only modified nodes/links are added or removed
|
||||
- **Smooth animations**: 300ms fade transitions for adding/removing elements
|
||||
- **In-place updates**: Existing nodes update their properties without being removed/re-added
|
||||
- **Preserved interactions**: Click, hover, and drag interactions work seamlessly with dynamic updates
|
||||
- **Efficient rendering**: D3's data binding with key functions ensures optimal DOM updates
|
||||
- **Intelligent simulation**: Uses different alpha values (0.1 for updates, 0.3 for additions/removals) to minimize disruption
|
||||
- **Drag-aware updates**: WebSocket updates are deferred while dragging and applied after drag completes
|
||||
- **Uninterrupted dragging**: Drag operations are never interrupted by incoming updates
|
||||
- **Rearrange button**: Convenient UI control to reset node layout and clear manual positioning
|
||||
|
||||
## How It Works
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
WebSocket Server (spore-ui backend)
|
||||
↓ (cluster_update / node_discovery events)
|
||||
WebSocketClient (api-client.js)
|
||||
↓ (emits clusterUpdate / nodeDiscovery events)
|
||||
TopologyViewModel.setupWebSocketListeners()
|
||||
↓ (builds graph data, updates state)
|
||||
TopologyGraphComponent subscriptions
|
||||
↓ (renderGraph() called automatically)
|
||||
├─ If dragging: queue update in pendingUpdate
|
||||
└─ If not dragging: apply update immediately
|
||||
D3.js enter/exit pattern
|
||||
↓ (smooth visual updates)
|
||||
Updated Topology Graph
|
||||
```
|
||||
|
||||
### Simplified Update Architecture
|
||||
|
||||
**Core Principle**: The D3 simulation is the single source of truth for positions.
|
||||
|
||||
#### How It Works:
|
||||
|
||||
1. **Drag Deferral**:
|
||||
- `isDragging` flag blocks updates during drag
|
||||
- Updates queued in `pendingUpdate` and applied after drag ends
|
||||
- Dragged positions saved in `draggedNodePositions` Map for persistence
|
||||
|
||||
2. **Position Merging** (in `updateNodes()`):
|
||||
- When simulation exists: copy live positions from simulation nodes to new data
|
||||
- This preserves ongoing animations and velocities
|
||||
- Then apply dragged positions (if any) as overrides
|
||||
- Result: Always use most current position state
|
||||
|
||||
3. **Smart Simulation Updates** (in `updateSimulation()`):
|
||||
- **Structural changes** (nodes added/removed): restart with alpha=0.3
|
||||
- **Property changes** (status, labels): DON'T restart - just update data
|
||||
- Simulation continues naturally for property-only changes
|
||||
- No unnecessary disruptions to ongoing animations
|
||||
|
||||
This ensures:
|
||||
- ✅ Simulation is authoritative for positions
|
||||
- ✅ No position jumping during animations
|
||||
- ✅ Property updates don't disrupt node movement
|
||||
- ✅ Dragged positions always respected
|
||||
- ✅ Simple, clean logic with one source of truth
|
||||
|
||||
### WebSocket Events Handled
|
||||
|
||||
1. **`clusterUpdate`** (from `cluster_update` message type)
|
||||
- Payload: `{ members: [...], primaryNode: string, totalNodes: number, timestamp: string }`
|
||||
- Action: Rebuilds graph with current cluster state
|
||||
|
||||
2. **`nodeDiscovery`** (from `node_discovery` message type)
|
||||
- Payload: `{ action: 'discovered' | 'stale', nodeIp: string, timestamp: string }`
|
||||
- Action: Triggers topology refresh after 500ms delay
|
||||
|
||||
3. **`connected`** (WebSocket connection established)
|
||||
- Action: Triggers topology refresh after 1000ms delay
|
||||
|
||||
4. **`disconnected`** (WebSocket connection lost)
|
||||
- Action: Logs disconnection (no action taken)
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Real-time Updates**: Topology reflects cluster state changes immediately
|
||||
2. **Smooth Transitions**: Nodes and links fade in/out gracefully
|
||||
3. **Better UX**: No manual refresh needed
|
||||
4. **Efficient**: Only updates changed elements, not entire graph
|
||||
5. **Resilient**: Automatically refreshes on reconnection
|
||||
6. **Consistent**: Uses same WebSocket infrastructure as ClusterStatusComponent
|
||||
|
||||
## Testing
|
||||
|
||||
To test the WebSocket integration:
|
||||
|
||||
1. **Start the application**:
|
||||
```bash
|
||||
cd spore-ui
|
||||
node index-standalone.js
|
||||
```
|
||||
|
||||
2. **Open the UI** and navigate to the Topology view
|
||||
|
||||
3. **Add a node**: Start a new SPORE device on the network
|
||||
- Watch it appear in the topology graph within seconds
|
||||
- Node should fade in smoothly
|
||||
|
||||
4. **Remove a node**: Stop a SPORE device
|
||||
- Watch it fade out from the topology graph
|
||||
- Connected links should also disappear
|
||||
|
||||
5. **Status changes**: Change node status (active → inactive → dead)
|
||||
- Node colors should update automatically
|
||||
- Status indicators should change
|
||||
|
||||
6. **Drag during updates**:
|
||||
- Start dragging a node
|
||||
- While dragging, trigger a cluster update (add/remove/change another node)
|
||||
- Drag should continue smoothly without interruption
|
||||
- After releasing, the update should be applied immediately
|
||||
- **Important**: The dragged node should stay at its final position, not revert
|
||||
|
||||
7. **Position persistence after drag**:
|
||||
- Drag a node to a new position and release
|
||||
- Trigger multiple WebSocket updates (status changes, new nodes, etc.)
|
||||
- The dragged node should remain in its new position through all updates
|
||||
- Only when the node is removed should its position be forgotten
|
||||
|
||||
8. **Update during animation**:
|
||||
- Let the graph settle (simulation running, nodes animating to stable positions)
|
||||
- While nodes are still moving, trigger a WebSocket update (status change)
|
||||
- **Expected**: Nodes should continue their smooth animation without jumping
|
||||
- **No flickering**: Positions should not snap back and forth
|
||||
- Animation should feel continuous and natural
|
||||
|
||||
9. **Single node scenario**:
|
||||
- Start with multiple nodes in the topology
|
||||
- Remove nodes one by one until only one remains
|
||||
- **Expected**: Single node stays visible, no "loading" message
|
||||
- Graph should render correctly with just one node
|
||||
- Remove the last node
|
||||
- **Expected**: "No cluster members found" message appears
|
||||
|
||||
10. **Rearrange nodes**:
|
||||
- Drag nodes to custom positions manually
|
||||
- Click the "Rearrange" button in the top-left corner
|
||||
- **Expected**: All nodes reset to physics-based positions
|
||||
- Dragged positions cleared, simulation restarts
|
||||
- Nodes animate to a clean, evenly distributed layout
|
||||
|
||||
11. **WebSocket reconnection**:
|
||||
- Disconnect from network briefly
|
||||
- Reconnect
|
||||
- Topology should refresh automatically
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Architecture
|
||||
- **Single Source of Truth**: D3 simulation manages all position state
|
||||
- **Key Functions**: D3 data binding uses node IPs as keys to track identity
|
||||
- **Transition Duration**: 300ms for fade in/out animations
|
||||
|
||||
### Position Management (Simplified!)
|
||||
- **updateNodes()**: Copies live positions from simulation to new data before binding
|
||||
- **No complex syncing**: Simulation state flows naturally to new data
|
||||
- **Dragged positions**: Override via `draggedNodePositions` Map (always respected)
|
||||
|
||||
### Simulation Behavior
|
||||
- **Structural changes** (add/remove nodes): Restart with alpha=0.3
|
||||
- **Property changes** (status, labels): No restart - data updated in-place
|
||||
- **Drag operations**: Simulation updates blocked entirely
|
||||
- **Result**: Smooth animations for property updates, controlled restart for structure changes
|
||||
|
||||
### Drag Management
|
||||
- **isDragging flag**: Blocks all updates during drag
|
||||
- **pendingUpdate**: Queues one update, applied 50ms after drag ends
|
||||
- **draggedNodePositions Map**: Persists manual positions across all updates
|
||||
- **Cleanup**: Map entries removed when nodes deleted
|
||||
|
||||
### Performance
|
||||
- **No unnecessary restarts**: Property-only updates don't disrupt simulation
|
||||
- **Efficient merging**: Position data copied via Map lookup (O(n))
|
||||
- **Memory efficient**: Only active nodes tracked, old entries cleaned up
|
||||
- **Smooth animations**: Velocity and momentum preserved across updates
|
||||
|
||||
### Edge Cases Handled
|
||||
- **Single node**: Graph renders correctly with just one node
|
||||
- **Transient states**: Loading/no-data states don't clear existing SVG
|
||||
- **Update races**: SVG preserved even if loading state triggered during render
|
||||
- **Empty to non-empty**: Smooth transition from loading to first node
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Possible improvements for future iterations:
|
||||
|
||||
1. **Diff-based updates**: Only rebuild graph when node/link structure actually changes
|
||||
2. **Visual indicators**: Show "new node" or "leaving node" badges temporarily
|
||||
3. **Connection health**: Real-time latency updates on links without full rebuild
|
||||
4. **Throttling**: Debounce rapid successive updates
|
||||
5. **Persistent layout**: Save and restore user-arranged topology layouts
|
||||
6. **Zoom to node**: Auto-zoom to newly added nodes with animation
|
||||
|
||||
## Related Files
|
||||
|
||||
- `spore-ui/public/scripts/view-models.js` - TopologyViewModel class
|
||||
- `spore-ui/public/scripts/components/TopologyGraphComponent.js` - Topology visualization component
|
||||
- `spore-ui/public/scripts/api-client.js` - WebSocketClient class
|
||||
- `spore-ui/index-standalone.js` - WebSocket server implementation
|
||||
|
||||
@@ -54,14 +54,6 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="random-primary-switcher">
|
||||
<button class="random-primary-toggle" id="random-primary-toggle" title="Select random primary node">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="theme-switcher">
|
||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
||||
@@ -72,13 +72,6 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setPrimaryNode(ip) {
|
||||
return this.request(`/api/discovery/primary/${encodeURIComponent(ip)}`, {
|
||||
method: 'POST',
|
||||
body: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
}
|
||||
|
||||
async getNodeStatus(ip) {
|
||||
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
@@ -77,65 +77,6 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
clusterStatusComponent.mount();
|
||||
logger.debug('App: Cluster status component initialized');
|
||||
|
||||
// Set up random primary node button
|
||||
logger.debug('App: Setting up random primary node button...');
|
||||
const randomPrimaryBtn = document.getElementById('random-primary-toggle');
|
||||
if (randomPrimaryBtn) {
|
||||
randomPrimaryBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
// Add spinning animation
|
||||
randomPrimaryBtn.classList.add('spinning');
|
||||
randomPrimaryBtn.disabled = true;
|
||||
|
||||
logger.debug('App: Selecting random primary node...');
|
||||
await clusterViewModel.selectRandomPrimaryNode();
|
||||
|
||||
// Show success state briefly
|
||||
logger.info('App: Random primary node selected successfully');
|
||||
|
||||
// Refresh topology to show new primary node connections
|
||||
// Wait a bit for the backend to update, then refresh topology
|
||||
setTimeout(async () => {
|
||||
logger.debug('App: Refreshing topology after primary node change...');
|
||||
try {
|
||||
await topologyViewModel.updateNetworkTopology();
|
||||
logger.debug('App: Topology refreshed successfully');
|
||||
} catch (error) {
|
||||
logger.error('App: Failed to refresh topology:', error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Also refresh cluster view to update member list with new primary
|
||||
setTimeout(async () => {
|
||||
logger.debug('App: Refreshing cluster view after primary node change...');
|
||||
try {
|
||||
if (clusterViewModel.updateClusterMembers) {
|
||||
await clusterViewModel.updateClusterMembers();
|
||||
}
|
||||
logger.debug('App: Cluster view refreshed successfully');
|
||||
} catch (error) {
|
||||
logger.error('App: Failed to refresh cluster view:', error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Remove spinning animation after delay
|
||||
setTimeout(() => {
|
||||
randomPrimaryBtn.classList.remove('spinning');
|
||||
randomPrimaryBtn.disabled = false;
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('App: Failed to select random primary node:', error);
|
||||
randomPrimaryBtn.classList.remove('spinning');
|
||||
randomPrimaryBtn.disabled = false;
|
||||
|
||||
// Show error notification (could be enhanced with a toast notification)
|
||||
alert('Failed to select random primary node: ' + error.message);
|
||||
}
|
||||
});
|
||||
logger.debug('App: Random primary node button configured');
|
||||
}
|
||||
|
||||
// Set up navigation event listeners
|
||||
logger.debug('App: Setting up navigation...');
|
||||
app.setupNavigation();
|
||||
|
||||
@@ -98,16 +98,7 @@ class TopologyGraphComponent extends Component {
|
||||
const chips = Object.entries(labels)
|
||||
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
||||
.join('');
|
||||
|
||||
// Add hint for non-primary nodes
|
||||
let hint = '';
|
||||
if (nodeData && !nodeData.isPrimary) {
|
||||
hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 255, 255, 0.7); text-align: center;">💡 Click to switch to primary & view details</div>';
|
||||
} else if (nodeData && nodeData.isPrimary) {
|
||||
hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 215, 0, 0.9); text-align: center;">⭐ Primary Node - Click to view details</div>';
|
||||
}
|
||||
|
||||
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>${hint}`;
|
||||
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>`;
|
||||
this.positionTooltip(pageX, pageY);
|
||||
this.tooltipEl.classList.add('visible');
|
||||
}
|
||||
@@ -319,7 +310,6 @@ class TopologyGraphComponent extends Component {
|
||||
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
|
||||
.style('border-radius', '12px');
|
||||
|
||||
|
||||
// Add zoom behavior
|
||||
this.zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 5])
|
||||
@@ -506,37 +496,25 @@ class TopologyGraphComponent extends Component {
|
||||
}
|
||||
|
||||
// Bind data with key function for proper enter/exit
|
||||
// D3's forceLink mutates source/target from strings to objects, so we need to normalize the key
|
||||
const link = linkGroup.selectAll('line')
|
||||
.data(links, d => {
|
||||
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
||||
// For directional links, maintain source -> target order in the key
|
||||
// For bidirectional links, use consistent ordering to avoid duplicates
|
||||
if (d.bidirectional) {
|
||||
return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`;
|
||||
} else {
|
||||
return `${sourceId}->${targetId}`;
|
||||
}
|
||||
});
|
||||
.data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
|
||||
|
||||
// Remove old links
|
||||
link.exit()
|
||||
.transition()
|
||||
.duration(600)
|
||||
.ease(d3.easeCubicOut)
|
||||
.duration(300)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
|
||||
// Add new links
|
||||
const linkEnter = link.enter().append('line')
|
||||
.attr('stroke-opacity', 0);
|
||||
.attr('stroke-opacity', 0)
|
||||
.attr('marker-end', null);
|
||||
|
||||
// Merge and update all links
|
||||
const linkMerge = linkEnter.merge(link)
|
||||
.transition()
|
||||
.duration(600)
|
||||
.ease(d3.easeCubicInOut)
|
||||
.duration(300)
|
||||
.attr('stroke', d => this.getLinkColor(d.latency))
|
||||
.attr('stroke-opacity', 0.7)
|
||||
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)));
|
||||
@@ -617,8 +595,8 @@ class TopologyGraphComponent extends Component {
|
||||
nodeEnter.append('circle')
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Status indicator
|
||||
nodeEnter.append('circle')
|
||||
@@ -627,26 +605,6 @@ class TopologyGraphComponent extends Component {
|
||||
.attr('cx', -8)
|
||||
.attr('cy', -8);
|
||||
|
||||
// Primary node badge
|
||||
const primaryBadge = nodeEnter.filter(d => d.isPrimary)
|
||||
.append('g')
|
||||
.attr('class', 'primary-badge')
|
||||
.attr('transform', 'translate(8, -8)');
|
||||
|
||||
primaryBadge.append('circle')
|
||||
.attr('r', 8)
|
||||
.attr('fill', '#FFD700')
|
||||
.attr('stroke', '#FFF')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
primaryBadge.append('text')
|
||||
.text('P')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('font-size', '10px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#000');
|
||||
|
||||
// Hostname
|
||||
nodeEnter.append('text')
|
||||
.attr('class', 'hostname-text')
|
||||
@@ -695,9 +653,7 @@ class TopologyGraphComponent extends Component {
|
||||
.transition()
|
||||
.duration(300)
|
||||
.attr('r', d => this.getNodeRadius(d.status))
|
||||
.attr('fill', d => this.getNodeColor(d.status))
|
||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
||||
.attr('fill', d => this.getNodeColor(d.status));
|
||||
|
||||
nodeMerge.select('circle:nth-child(2)')
|
||||
.transition()
|
||||
@@ -720,37 +676,6 @@ class TopologyGraphComponent extends Component {
|
||||
.duration(300)
|
||||
.attr('fill', d => this.getNodeColor(d.status));
|
||||
|
||||
// Update primary badge for existing nodes
|
||||
// Remove badge from nodes that are no longer primary
|
||||
nodeMerge.filter(d => !d.isPrimary)
|
||||
.select('.primary-badge')
|
||||
.remove();
|
||||
|
||||
// Add badge to nodes that became primary (if they don't already have one)
|
||||
nodeMerge.filter(d => d.isPrimary)
|
||||
.each(function(d) {
|
||||
const nodeGroup = d3.select(this);
|
||||
if (nodeGroup.select('.primary-badge').empty()) {
|
||||
const badge = nodeGroup.append('g')
|
||||
.attr('class', 'primary-badge')
|
||||
.attr('transform', 'translate(8, -8)');
|
||||
|
||||
badge.append('circle')
|
||||
.attr('r', 8)
|
||||
.attr('fill', '#FFD700')
|
||||
.attr('stroke', '#FFF')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
badge.append('text')
|
||||
.text('P')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('font-size', '10px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#000');
|
||||
}
|
||||
});
|
||||
|
||||
// Fade in only new nodes (not existing ones)
|
||||
nodeEnter.transition()
|
||||
.duration(300)
|
||||
@@ -764,10 +689,7 @@ class TopologyGraphComponent extends Component {
|
||||
|
||||
// Add interactions
|
||||
this.nodeSelection
|
||||
.on('click', async (event, d) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// Always open drawer/details for the clicked node
|
||||
.on('click', (event, d) => {
|
||||
this.viewModel.selectNode(d.id);
|
||||
this.updateSelection(d.id);
|
||||
if (this.isDesktop()) {
|
||||
@@ -775,53 +697,6 @@ class TopologyGraphComponent extends Component {
|
||||
} else {
|
||||
this.showMemberCardOverlay(d);
|
||||
}
|
||||
|
||||
// If clicking on non-primary node, also switch it to primary
|
||||
if (!d.isPrimary) {
|
||||
try {
|
||||
// Visual feedback - highlight the clicked node
|
||||
d3.select(event.currentTarget).select('circle')
|
||||
.transition()
|
||||
.duration(200)
|
||||
.attr('stroke-width', 4)
|
||||
.attr('stroke', '#FFD700');
|
||||
|
||||
logger.info(`TopologyGraphComponent: Switching primary to ${d.ip}`);
|
||||
|
||||
// Switch to this node as primary
|
||||
await this.viewModel.switchToPrimaryNode(d.ip);
|
||||
|
||||
// Show success feedback briefly
|
||||
this.showTooltip({
|
||||
...d,
|
||||
hostname: `✓ Switched to ${d.hostname}`
|
||||
}, event.pageX, event.pageY);
|
||||
|
||||
setTimeout(() => {
|
||||
this.hideTooltip();
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('TopologyGraphComponent: Failed to switch primary:', error);
|
||||
|
||||
// Show error feedback
|
||||
this.showTooltip({
|
||||
...d,
|
||||
hostname: `✗ Failed: ${error.message}`
|
||||
}, event.pageX, event.pageY);
|
||||
|
||||
setTimeout(() => {
|
||||
this.hideTooltip();
|
||||
}, 3000);
|
||||
|
||||
// Revert visual feedback
|
||||
d3.select(event.currentTarget).select('circle')
|
||||
.transition()
|
||||
.duration(200)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke', '#fff');
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('mouseover', (event, d) => {
|
||||
d3.select(event.currentTarget).select('circle')
|
||||
@@ -847,13 +722,9 @@ class TopologyGraphComponent extends Component {
|
||||
linkLabelGroup = svgGroup.append('g').attr('class', 'link-label-group graph-element');
|
||||
}
|
||||
|
||||
// Bind data with same key function as updateLinks for consistency
|
||||
// Bind data
|
||||
const linkLabels = linkLabelGroup.selectAll('text')
|
||||
.data(links, d => {
|
||||
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
||||
return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`;
|
||||
});
|
||||
.data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
|
||||
|
||||
// Remove old labels
|
||||
linkLabels.exit()
|
||||
@@ -927,72 +798,18 @@ class TopologyGraphComponent extends Component {
|
||||
const currentNodeIds = new Set(currentNodes.map(n => n.id));
|
||||
const newNodeIds = new Set(nodes.map(n => n.id));
|
||||
|
||||
// Check if nodes changed
|
||||
const nodesChanged = currentNodes.length !== nodes.length ||
|
||||
[...newNodeIds].some(id => !currentNodeIds.has(id)) ||
|
||||
[...currentNodeIds].some(id => !newNodeIds.has(id));
|
||||
|
||||
// Check if links changed (compare link keys)
|
||||
const currentLinks = this.simulation.force('link').links();
|
||||
const currentLinkKeys = new Set(currentLinks.map(l => {
|
||||
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
return `${sourceId}->${targetId}`;
|
||||
}));
|
||||
const newLinkKeys = new Set(links.map(l => {
|
||||
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
return `${sourceId}->${targetId}`;
|
||||
}));
|
||||
|
||||
const linksChanged = currentLinks.length !== links.length ||
|
||||
[...newLinkKeys].some(key => !currentLinkKeys.has(key)) ||
|
||||
[...currentLinkKeys].some(key => !newLinkKeys.has(key));
|
||||
|
||||
const isStructuralChange = nodesChanged || linksChanged;
|
||||
const isStructuralChange = currentNodes.length !== nodes.length ||
|
||||
[...newNodeIds].some(id => !currentNodeIds.has(id)) ||
|
||||
[...currentNodeIds].some(id => !newNodeIds.has(id));
|
||||
|
||||
// Update simulation data
|
||||
this.simulation.nodes(nodes);
|
||||
this.simulation.force('link').links(links);
|
||||
|
||||
if (isStructuralChange) {
|
||||
if (linksChanged && !nodesChanged) {
|
||||
// Only links changed (e.g., primary node switch)
|
||||
// Keep nodes in place, just update link positions
|
||||
logger.debug('TopologyGraphComponent: Link structure changed (primary switch), keeping node positions');
|
||||
|
||||
// Stop the simulation completely
|
||||
this.simulation.stop();
|
||||
|
||||
// Trigger a single tick to update link positions with the new data
|
||||
// This ensures D3's forceLink properly connects sources and targets
|
||||
this.simulation.tick();
|
||||
|
||||
// Manually update the visual positions immediately
|
||||
if (this.linkSelection) {
|
||||
this.linkSelection
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
}
|
||||
|
||||
if (this.linkLabelSelection) {
|
||||
this.linkLabelSelection
|
||||
.attr('x', d => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
|
||||
}
|
||||
|
||||
// Keep simulation stopped - nodes won't move
|
||||
} else if (nodesChanged) {
|
||||
// Nodes added/removed: use moderate restart
|
||||
logger.debug('TopologyGraphComponent: Node structure changed, moderate restart');
|
||||
this.simulation.alpha(0.3).restart();
|
||||
} else {
|
||||
// Just links changed with same nodes
|
||||
logger.debug('TopologyGraphComponent: Link structure changed, restarting simulation');
|
||||
this.simulation.alpha(0.2).restart();
|
||||
}
|
||||
// Structural change: restart with moderate alpha
|
||||
logger.debug('TopologyGraphComponent: Structural change detected, restarting simulation');
|
||||
this.simulation.alpha(0.3).restart();
|
||||
} else {
|
||||
// Property-only change: just update data, no restart needed
|
||||
// The simulation keeps running at its current alpha
|
||||
@@ -1014,8 +831,8 @@ class TopologyGraphComponent extends Component {
|
||||
.style('opacity', '0');
|
||||
|
||||
legend.append('rect')
|
||||
.attr('width', 420)
|
||||
.attr('height', 140)
|
||||
.attr('width', 320)
|
||||
.attr('height', 120)
|
||||
.attr('fill', 'rgba(0, 0, 0, 0.7)')
|
||||
.attr('rx', 8)
|
||||
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
|
||||
@@ -1086,57 +903,6 @@ class TopologyGraphComponent extends Component {
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', '#ecf0f1');
|
||||
});
|
||||
|
||||
const topologyLegend = legend.append('g')
|
||||
.attr('transform', 'translate(280, 20)');
|
||||
|
||||
topologyLegend.append('text')
|
||||
.text('Topology:')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('font-size', '14px')
|
||||
.attr('font-weight', '600')
|
||||
.attr('fill', '#ecf0f1');
|
||||
|
||||
// Primary node indicator
|
||||
const primaryBadge = topologyLegend.append('g')
|
||||
.attr('transform', 'translate(0, 20)');
|
||||
|
||||
primaryBadge.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#FFD700')
|
||||
.attr('stroke', '#FFF')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
primaryBadge.append('text')
|
||||
.text('P')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('font-size', '8px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#000');
|
||||
|
||||
topologyLegend.append('text')
|
||||
.text('Primary Node')
|
||||
.attr('x', 15)
|
||||
.attr('y', 24)
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', '#ecf0f1');
|
||||
|
||||
// Star topology info
|
||||
topologyLegend.append('text')
|
||||
.text('Star Topology')
|
||||
.attr('x', 0)
|
||||
.attr('y', 50)
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', '#ecf0f1');
|
||||
|
||||
topologyLegend.append('text')
|
||||
.text('(Primary to Members)')
|
||||
.attr('x', 0)
|
||||
.attr('y', 65)
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', 'rgba(236, 240, 241, 0.7)');
|
||||
}
|
||||
|
||||
getNodeRadius(status) {
|
||||
|
||||
@@ -601,14 +601,13 @@ class TopologyViewModel extends ViewModel {
|
||||
|
||||
// Update topology from WebSocket data
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members, primary: ${data.primaryNode}`);
|
||||
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members`);
|
||||
|
||||
// Build enhanced graph data from updated members with primary node info
|
||||
this.buildEnhancedGraphData(data.members, data.primaryNode).then(({ nodes, links }) => {
|
||||
// Build enhanced graph data from updated members
|
||||
this.buildEnhancedGraphData(data.members).then(({ nodes, links }) => {
|
||||
this.batchUpdate({
|
||||
nodes: nodes,
|
||||
links: links,
|
||||
primaryNode: data.primaryNode,
|
||||
lastUpdateTime: data.timestamp || new Date().toISOString()
|
||||
});
|
||||
}).catch(error => {
|
||||
@@ -659,24 +658,14 @@ class TopologyViewModel extends ViewModel {
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
logger.debug('TopologyViewModel: Got cluster members response:', response);
|
||||
|
||||
// Get discovery info to find the primary node
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
logger.debug('TopologyViewModel: Got discovery info:', discoveryInfo);
|
||||
|
||||
const members = response.members || [];
|
||||
const primaryNode = discoveryInfo.primaryNode || null;
|
||||
|
||||
logger.debug(`TopologyViewModel: Building graph with ${members.length} members, primary: ${primaryNode}`);
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
const { nodes, links } = await this.buildEnhancedGraphData(members, primaryNode);
|
||||
|
||||
logger.debug(`TopologyViewModel: Built graph with ${nodes.length} nodes and ${links.length} links`);
|
||||
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
||||
|
||||
this.batchUpdate({
|
||||
nodes: nodes,
|
||||
links: links,
|
||||
primaryNode: primaryNode,
|
||||
lastUpdateTime: new Date().toISOString()
|
||||
});
|
||||
|
||||
@@ -690,8 +679,7 @@ class TopologyViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
// Creates a star topology with the primary node at the center
|
||||
async buildEnhancedGraphData(members, primaryNode) {
|
||||
async buildEnhancedGraphData(members) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
|
||||
@@ -703,7 +691,6 @@ class TopologyViewModel extends ViewModel {
|
||||
members.forEach((member, index) => {
|
||||
if (member && member.ip) {
|
||||
const existingNode = existingNodeMap.get(member.ip);
|
||||
const isPrimary = member.ip === primaryNode;
|
||||
|
||||
nodes.push({
|
||||
id: member.ip,
|
||||
@@ -711,7 +698,6 @@ class TopologyViewModel extends ViewModel {
|
||||
ip: member.ip,
|
||||
status: member.status || 'UNKNOWN',
|
||||
latency: member.latency || 0,
|
||||
isPrimary: isPrimary,
|
||||
// Preserve both legacy 'resources' and preferred 'labels'
|
||||
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
||||
resources: member.resources || {},
|
||||
@@ -728,32 +714,28 @@ class TopologyViewModel extends ViewModel {
|
||||
}
|
||||
});
|
||||
|
||||
// Build links - create a star topology with primary node at center
|
||||
// Only create links from the primary node to each member
|
||||
// The cluster data comes from the primary, so it only knows about its direct connections
|
||||
if (primaryNode) {
|
||||
logger.debug(`TopologyViewModel: Creating star topology with primary ${primaryNode}`);
|
||||
nodes.forEach(node => {
|
||||
// Create a link from primary to each non-primary node
|
||||
if (node.ip !== primaryNode) {
|
||||
const member = members.find(m => m.ip === node.ip);
|
||||
const latency = member?.latency || this.estimateLatency(node, { ip: primaryNode });
|
||||
// Build links - create a mesh topology from the members data
|
||||
// All connections are inferred from the cluster membership
|
||||
// No additional API calls needed - all data comes from websocket updates
|
||||
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];
|
||||
|
||||
logger.debug(`TopologyViewModel: Creating link from ${primaryNode} to ${node.ip} (latency: ${latency}ms)`);
|
||||
// Use the latency from the member data if available, otherwise estimate
|
||||
const sourceMember = members.find(m => m.ip === sourceNode.ip);
|
||||
const targetMember = members.find(m => m.ip === targetNode.ip);
|
||||
const latency = targetMember?.latency || sourceMember?.latency || this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: primaryNode,
|
||||
target: node.id,
|
||||
latency: latency,
|
||||
sourceNode: nodes.find(n => n.ip === primaryNode),
|
||||
targetNode: node,
|
||||
bidirectional: false // Primary -> Member is directional
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.debug(`TopologyViewModel: Created ${links.length} links from primary node`);
|
||||
} else {
|
||||
logger.warn('TopologyViewModel: No primary node specified, cannot create links');
|
||||
links.push({
|
||||
source: sourceNode.id,
|
||||
target: targetNode.id,
|
||||
latency: latency,
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
bidirectional: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
@@ -776,31 +758,6 @@ class TopologyViewModel extends ViewModel {
|
||||
clearSelection() {
|
||||
this.set('selectedNode', null);
|
||||
}
|
||||
|
||||
// Switch to a specific node as the new primary
|
||||
async switchToPrimaryNode(nodeIp) {
|
||||
try {
|
||||
logger.debug(`TopologyViewModel: Switching primary node to ${nodeIp}`);
|
||||
const result = await window.apiClient.setPrimaryNode(nodeIp);
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`TopologyViewModel: Successfully switched primary to ${nodeIp}`);
|
||||
|
||||
// Update topology after a short delay to allow backend to update
|
||||
setTimeout(async () => {
|
||||
logger.debug('TopologyViewModel: Refreshing topology after primary switch...');
|
||||
await this.updateNetworkTopology();
|
||||
}, 1000);
|
||||
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to switch primary node');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('TopologyViewModel: Failed to switch primary node:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Monitoring View Model for cluster resource monitoring
|
||||
|
||||
@@ -68,7 +68,6 @@ button:disabled {
|
||||
/* === Icon-Only Button Style (Minimal) === */
|
||||
.btn-icon,
|
||||
.theme-toggle,
|
||||
.random-primary-toggle,
|
||||
.burger-btn,
|
||||
.primary-node-refresh,
|
||||
.filter-pill-remove,
|
||||
@@ -97,7 +96,6 @@ button:disabled {
|
||||
|
||||
.btn-icon:hover,
|
||||
.theme-toggle:hover,
|
||||
.random-primary-toggle:hover,
|
||||
.burger-btn:hover,
|
||||
.primary-node-refresh:hover,
|
||||
.filter-pill-remove:hover,
|
||||
@@ -529,44 +527,6 @@ p {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.random-primary-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.random-primary-toggle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.random-primary-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.random-primary-toggle:hover svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.random-primary-toggle.spinning svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.random-primary-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Topology graph node interactions */
|
||||
#topology-graph-container .node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#topology-graph-container .node:hover circle:first-child {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
Reference in New Issue
Block a user