Merge pull request 'feature/refactoring' (#3) from feature/refactoring into main

Reviewed-on: #3
This commit is contained in:
2025-09-02 13:26:12 +02:00
21 changed files with 3456 additions and 4047 deletions

View File

@@ -1,266 +0,0 @@
# SPORE UI State Preservation System
## Overview
The SPORE UI framework now includes an advanced state preservation system that prevents UI state loss during data refreshes. This system ensures that user interactions like expanded cards, active tabs, and other UI state are maintained when data is updated from the server.
## Key Features
### 1. **UI State Persistence**
- **Expanded Cards**: When cluster member cards are expanded, their state is preserved across data refreshes
- **Active Tabs**: Active tab selections within node detail views are maintained
- **User Interactions**: All user-initiated UI changes are stored and restored automatically
### 2. **Smart Data Updates**
- **Change Detection**: The system detects when data has actually changed and only updates what's necessary
- **Partial Updates**: Components can update specific data without re-rendering the entire UI
- **State Preservation**: UI state is automatically preserved during all data operations
### 3. **Efficient Rendering**
- **No Full Re-renders**: Components avoid unnecessary full re-renders when only data changes
- **Granular Updates**: Only changed properties trigger UI updates
- **Performance Optimization**: Reduced DOM manipulation and improved user experience
## Architecture
### Enhanced ViewModel Class
The base `ViewModel` class now includes:
```javascript
class ViewModel {
// UI State Management
setUIState(key, value) // Store UI state
getUIState(key) // Retrieve UI state
getAllUIState() // Get all stored UI state
clearUIState(key) // Clear specific or all UI state
// Change Detection
hasChanged(property) // Check if property changed
getPrevious(property) // Get previous value
// Batch Updates
batchUpdate(updates, options) // Update multiple properties with state preservation
}
```
### Enhanced Component Class
The base `Component` class now includes:
```javascript
class Component {
// UI State Management
setUIState(key, value) // Store local UI state
getUIState(key) // Get local or view model state
getAllUIState() // Get merged state
restoreUIState() // Restore state from view model
// Partial Updates
updatePartial(property, newValue, previousValue) // Handle partial updates
}
```
## Implementation Examples
### 1. **Cluster Members Component**
The `ClusterMembersComponent` demonstrates state preservation:
```javascript
class ClusterMembersComponent extends Component {
setupViewModelListeners() {
// Listen with change detection
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
}
handleMembersUpdate(newMembers, previousMembers) {
if (this.shouldPreserveState(newMembers, previousMembers)) {
// Partial update preserves UI state
this.updateMembersPartially(newMembers, previousMembers);
} else {
// Full re-render only when necessary
this.render();
}
}
shouldPreserveState(newMembers, previousMembers) {
// Check if member structure allows state preservation
if (newMembers.length !== previousMembers.length) return false;
const newIps = new Set(newMembers.map(m => m.ip));
const prevIps = new Set(previousMembers.map(m => m.ip));
return newIps.size === prevIps.size &&
[...newIps].every(ip => prevIps.has(ip));
}
}
```
### 2. **Node Details Component**
The `NodeDetailsComponent` preserves active tab state:
```javascript
class NodeDetailsComponent extends Component {
setupViewModelListeners() {
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
}
handleActiveTabUpdate(newTab, previousTab) {
// Update tab UI without full re-render
this.updateActiveTab(newTab, previousTab);
}
updateActiveTab(newTab) {
// Update only the tab UI, preserving other state
const tabButtons = this.findAllElements('.tab-button');
const tabContents = this.findAllElements('.tab-content');
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
const activeButton = this.findElement(`[data-tab="${newTab}"]`);
const activeContent = this.findElement(`#${newTab}-tab`);
if (activeButton) activeButton.classList.add('active');
if (activeContent) activeContent.classList.add('active');
}
}
```
## Usage Patterns
### 1. **Storing UI State**
```javascript
// In a component
this.setUIState('expandedCard', memberIp);
this.setUIState('activeTab', 'firmware');
// In a view model
this.setUIState('userPreferences', { theme: 'dark', layout: 'compact' });
```
### 2. **Retrieving UI State**
```javascript
// Get specific state
const expandedCard = this.getUIState('expandedCard');
const activeTab = this.getUIState('activeTab');
// Get all state
const allState = this.getAllUIState();
```
### 3. **Batch Updates with State Preservation**
```javascript
// Update data while preserving UI state
this.viewModel.batchUpdate({
members: newMembers,
lastUpdateTime: new Date().toISOString()
}, { preserveUIState: true });
```
### 4. **Smart Updates**
```javascript
// Use smart update to preserve state
await this.viewModel.smartUpdate();
```
## Benefits
### 1. **Improved User Experience**
- Users don't lose their place in the interface
- Expanded cards remain expanded
- Active tabs stay selected
- No jarring UI resets
### 2. **Better Performance**
- Reduced unnecessary DOM manipulation
- Efficient partial updates
- Optimized rendering cycles
### 3. **Maintainable Code**
- Clear separation of concerns
- Consistent state management patterns
- Easy to extend and modify
## Testing
Use the `test-state-preservation.html` file to test the state preservation system:
1. **Expand cluster member cards**
2. **Change active tabs in node details**
3. **Trigger data refresh**
4. **Verify state is preserved**
## Migration Guide
### From Old System
If you're upgrading from the old system:
1. **Update ViewModel Listeners**: Change from `this.render.bind(this)` to specific update handlers
2. **Add State Management**: Use `setUIState()` and `getUIState()` for UI state
3. **Implement Partial Updates**: Override `updatePartial()` method for efficient updates
4. **Use Smart Updates**: Replace direct data updates with `smartUpdate()` calls
### Example Migration
**Old Code:**
```javascript
this.subscribeToProperty('members', this.render.bind(this));
async handleRefresh() {
await this.viewModel.updateClusterMembers();
}
```
**New Code:**
```javascript
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
async handleRefresh() {
await this.viewModel.smartUpdate();
}
handleMembersUpdate(newMembers, previousMembers) {
if (this.shouldPreserveState(newMembers, previousMembers)) {
this.updateMembersPartially(newMembers, previousMembers);
} else {
this.render();
}
}
```
## Best Practices
1. **Always Store UI State**: Use `setUIState()` for any user interaction
2. **Implement Partial Updates**: Override `updatePartial()` for efficient updates
3. **Use Change Detection**: Leverage `hasChanged()` to avoid unnecessary updates
4. **Batch Related Updates**: Use `batchUpdate()` for multiple property changes
5. **Test State Preservation**: Verify that UI state is maintained during data refreshes
## Troubleshooting
### Common Issues
1. **State Not Preserved**: Ensure you're using `setUIState()` and `getUIState()`
2. **Full Re-renders**: Check if `shouldPreserveState()` logic is correct
3. **Performance Issues**: Verify you're using partial updates instead of full renders
### Debug Tips
1. **Enable Console Logging**: Check browser console for state preservation logs
2. **Use State Indicators**: Monitor state changes in the test interface
3. **Verify Change Detection**: Ensure `hasChanged()` is working correctly
## Future Enhancements
- **State Synchronization**: Real-time state sync across multiple browser tabs
- **Advanced Change Detection**: Deep object comparison for complex data structures
- **State Persistence**: Save UI state to localStorage for session persistence
- **State Rollback**: Ability to revert to previous UI states

View File

@@ -1,146 +0,0 @@
# 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

@@ -1,223 +0,0 @@
# View Switching Fixes for Member Card Issues
## Problem Description
When switching between the cluster and firmware views, member cards were experiencing:
- **Wrong UI state**: Expanded cards, active tabs, and other UI state was being lost
- **Flickering**: Visual glitches and rapid re-rendering during view switches
- **Broken functionality**: Member cards not working properly after view switches
- **Inefficient rendering**: Components were completely unmounted and remounted on every view switch
- **Incorrect state restoration**: UI state was incorrectly restored on first load (all cards expanded, wrong tabs active)
## Root Causes Identified
1. **Aggressive DOM Manipulation**: Complete component unmounting/remounting on every view switch
2. **Race Conditions**: Multiple async operations and timeouts interfering with each other
3. **State Loss**: UI state not properly preserved across view switches
4. **Rapid Navigation**: Multiple rapid clicks could cause navigation conflicts
5. **CSS Transition Conflicts**: Multiple transitions causing visual flickering
6. **No Component Caching**: Every view switch created new component instances
7. **Complex State Restoration**: Attempting to restore UI state caused incorrect behavior on first load
## Fixes Implemented
### 1. **Component Caching System** (`framework.js`)
- **Component Cache**: Components are created once and cached, never re-created
- **Pause/Resume Pattern**: Components are paused (not unmounted) when switching away
- **Pre-initialization**: Components are created during route registration for better performance
- **Simple Show/Hide**: Components are just shown/hidden without touching UI state
### 2. **Enhanced Navigation System** (`framework.js`)
- **Debounced Navigation**: Added 300ms cooldown between navigation requests
- **Navigation Queue**: Queues navigation requests when one is already in progress
- **Smooth Transitions**: Added opacity transitions to prevent abrupt view changes
- **No Component Destruction**: Components are kept alive and just paused/resumed
### 3. **Simplified State Management** (`view-models.js`)
- **No UI State Persistence**: Removed complex localStorage state restoration
- **Clean State on Load**: Components start with default state (collapsed cards, status tab)
- **No State Corruption**: Eliminates incorrect state restoration on first load
### 4. **Enhanced Component Lifecycle** (`components.js`)
- **Pause/Resume Methods**: Components can be paused and resumed without losing state
- **Default State**: Member cards always start collapsed, tabs start on 'status'
- **No State Restoration**: Components maintain their current state without external interference
- **Render Guards**: Prevents multiple simultaneous render operations
- **View Switch Detection**: Skips rendering during view transitions
- **Improved Unmounting**: Better cleanup of timeouts and event listeners
- **State Tracking**: Tracks if data has already been loaded to prevent unnecessary reloads
### 5. **CSS Improvements** (`styles.css`)
- **Smooth Transitions**: Added fade-in/fade-out animations for view switching
- **Reduced Transition Times**: Shortened member card transitions from 0.3s to 0.2s
- **Better Animations**: Improved expand/collapse animations for member cards
- **Loading States**: Added fade-in animations for loading, error, and empty states
### 6. **View Model Enhancements**
- **Smart Updates**: Only updates changed data to minimize re-renders
- **Change Detection**: Compares data before triggering updates
- **Clean Initialization**: No complex state restoration logic
## Technical Details
### Component Caching Flow
1. **Route Registration**: Components are created and cached during app initialization
2. **Navigation**: When switching views, current component is paused (not unmounted)
3. **State Preservation**: All component state, DOM, and event listeners remain intact
4. **Resume**: When returning to a view, component is resumed from paused state
5. **No Re-rendering**: Components maintain their exact state and appearance
6. **Simple Show/Hide**: No complex state restoration, just show/hide components
### Pause/Resume Pattern
```javascript
// Component is paused instead of unmounted
onPause() {
// Clear timers, pause operations
// Component state and DOM remain intact
}
onResume() {
// Restore timers, resume operations
// No re-rendering needed
}
```
### Navigation Flow
1. **Cooldown Check**: 300ms minimum between navigation requests
2. **Queue Management**: Multiple requests queued and processed sequentially
3. **Pause Current**: Current component paused (opacity: 0)
4. **Show New View**: New view becomes visible with fade-in animation
5. **Resume Component**: Cached component resumed from paused state
6. **No Unmounting**: Components are never destroyed during view switches
7. **No State Touch**: UI state is not modified during view switches
### State Management
- **Default State**: Member cards start collapsed, tabs start on 'status'
- **No Persistence**: No localStorage state restoration
- **Clean Initialization**: Components always start with predictable state
- **No State Corruption**: Eliminates incorrect state restoration issues
### Render Optimization
- **No Re-rendering**: Components maintain their exact state across view switches
- **Pause/Resume**: Components are paused instead of unmounted
- **State Persistence**: All UI state preserved in memory (not localStorage)
- **Change Detection**: Only updates changed data when resuming
- **Default Behavior**: Always starts with clean, predictable state
## Testing
Use the test page `test-view-switching.html` to verify fixes:
1. **Rapid Switching Test**: Clicks navigation tabs rapidly to test cooldown
2. **State Preservation Test**: Expands cards, switches views, verifies state restoration
3. **Component Caching Test**: Verify components are not re-created on view switches
4. **Default State Test**: Verify components start with correct default state
5. **Console Monitoring**: Check console for detailed operation logs
## Expected Results
After implementing these fixes:
-**No More Re-rendering**: Components are cached and never re-created
-**No More Flickering**: Smooth transitions between views
-**Correct Default State**: Member cards start collapsed, tabs start on 'status'
-**No State Corruption**: No incorrect state restoration on first load
-**Stable Navigation**: No more broken member cards after view switches
-**Better Performance**: No unnecessary component creation/destruction
-**Improved UX**: Smoother, more professional feel
-**Memory Efficiency**: Components reused instead of recreated
-**Predictable Behavior**: Components always start with clean state
## Configuration
### Navigation Cooldown
```javascript
this.navigationCooldown = 300; // 300ms between navigation requests
```
### Component Caching
```javascript
// Components are automatically cached during route registration
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
```
### Transition Timing
```css
.view-content {
transition: opacity 0.2s ease-in-out;
}
```
### Member Card Transitions
```css
.member-card {
transition: all 0.2s ease;
}
```
## Architecture Benefits
### 1. **Performance**
- No component recreation on view switches
- Faster view transitions
- Reduced memory allocation/deallocation
### 2. **State Management**
- Clean, predictable default state
- No state corruption on first load
- Consistent user experience
### 3. **Maintainability**
- Cleaner component lifecycle
- No complex state restoration logic
- Easier debugging and testing
- More predictable behavior
### 4. **User Experience**
- No flickering or visual glitches
- Instant view switching
- Maintained user context
- Predictable component behavior
## Key Changes Made
### Removed Complex State Restoration
-`preserveUIState()` method
-`restoreUIState()` method
- ❌ localStorage state persistence
- ❌ Complex tab state restoration
- ❌ Expanded card state restoration
### Simplified Component Behavior
- ✅ Components start with default state
- ✅ Member cards always start collapsed
- ✅ Tabs always start on 'status'
- ✅ No external state interference
- ✅ Clean, predictable initialization
### Maintained Performance Benefits
- ✅ Component caching still works
- ✅ No re-rendering on view switches
- ✅ Smooth transitions
- ✅ Better memory efficiency
## Future Improvements
1. **Virtual Scrolling**: For large numbers of member cards
2. **Animation Preferences**: User-configurable transition speeds
3. **State Sync**: Real-time state synchronization across multiple tabs
4. **Performance Metrics**: Track and optimize render performance
5. **Lazy Loading**: Load components only when first accessed
6. **Memory Management**: Intelligent cache cleanup for unused components
7. **User Preferences**: Allow users to set default states if desired

View File

@@ -71,17 +71,6 @@
<div id="firmware-view" class="view-content"> <div id="firmware-view" class="view-content">
<div class="firmware-section"> <div class="firmware-section">
<!--div class="firmware-header">
<div class="firmware-header-left"></div>
<button class="refresh-btn" onclick="refreshFirmwareView()">
<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="firmware-container"> <div id="firmware-container">
<div class="firmware-overview"> <div class="firmware-overview">
<div class="firmware-actions"> <div class="firmware-actions">
@@ -143,10 +132,21 @@
</div> </div>
<script src="./vendor/d3.v7.min.js"></script> <script src="./vendor/d3.v7.min.js"></script>
<script src="./scripts/constants.js"></script>
<script src="./scripts/framework.js"></script> <script src="./scripts/framework.js"></script>
<script src="./scripts/api-client.js"></script> <script src="./scripts/api-client.js"></script>
<script src="./scripts/view-models.js"></script> <script src="./scripts/view-models.js"></script>
<script src="./scripts/components.js"></script> <!-- Base/leaf components first -->
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
<script src="./scripts/components/NodeDetailsComponent.js"></script>
<script src="./scripts/components/ClusterMembersComponent.js"></script>
<script src="./scripts/components/FirmwareComponent.js"></script>
<!-- Container/view components after their deps -->
<script src="./scripts/components/FirmwareViewComponent.js"></script>
<script src="./scripts/components/ClusterViewComponent.js"></script>
<script src="./scripts/components/ClusterStatusComponent.js"></script>
<script src="./scripts/components/TopologyGraphComponent.js"></script>
<script src="./scripts/components/ComponentsLoader.js"></script>
<script src="./scripts/app.js"></script> <script src="./scripts/app.js"></script>
</body> </body>

View File

@@ -15,7 +15,7 @@ class ApiClient {
this.baseUrl = `http://${currentHost}:3001`; this.baseUrl = `http://${currentHost}:3001`;
} }
console.log('API Client initialized with base URL:', this.baseUrl); logger.debug('API Client initialized with base URL:', this.baseUrl);
} }
async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) { async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) {
@@ -51,92 +51,56 @@ class ApiClient {
} }
async getClusterMembers() { async getClusterMembers() {
try { return this.request('/api/cluster/members', { method: 'GET' });
return await this.request('/api/cluster/members', { method: 'GET' });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getClusterMembersFromNode(ip) { async getClusterMembersFromNode(ip) {
try { return this.request(`/api/cluster/members`, {
return await this.request(`/api/cluster/members`, { method: 'GET',
method: 'GET', query: { ip: ip }
query: { ip: ip } });
});
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getDiscoveryInfo() { async getDiscoveryInfo() {
try { return this.request('/api/discovery/nodes', { method: 'GET' });
return await this.request('/api/discovery/nodes', { method: 'GET' });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async selectRandomPrimaryNode() { async selectRandomPrimaryNode() {
try { return this.request('/api/discovery/random-primary', {
return await this.request('/api/discovery/random-primary', { method: 'POST',
method: 'POST', body: { timestamp: new Date().toISOString() }
body: { timestamp: new Date().toISOString() } });
});
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getNodeStatus(ip) { async getNodeStatus(ip) {
try { return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
return await this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getTasksStatus(ip) { async getTasksStatus(ip) {
try { return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
return await this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getCapabilities(ip) { async getCapabilities(ip) {
try { return this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
return await this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async callCapability({ ip, method, uri, params }) { async callCapability({ ip, method, uri, params }) {
try { return this.request('/api/proxy-call', {
return await this.request('/api/proxy-call', { method: 'POST',
method: 'POST', body: { ip, method, uri, params }
body: { ip, method, uri, params } });
});
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async uploadFirmware(file, nodeIp) { async uploadFirmware(file, nodeIp) {
try { const formData = new FormData();
const formData = new FormData(); formData.append('file', file);
formData.append('file', file); return this.request(`/api/node/update`, {
return await this.request(`/api/node/update`, { method: 'POST',
method: 'POST', query: { ip: nodeIp },
query: { ip: nodeIp }, body: formData,
body: formData, isForm: true,
isForm: true, headers: {},
headers: {}, });
});
} catch (error) {
throw new Error(`Upload failed: ${error.message}`);
}
} }
} }

View File

@@ -1,23 +1,25 @@
// Main SPORE UI Application // Main SPORE UI Application
// Initialize the application when DOM is loaded // Initialize the application when DOM is loaded
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', async function() {
console.log('=== SPORE UI Application Initialization ==='); logger.debug('=== SPORE UI Application Initialization ===');
// Initialize the framework (but don't navigate yet) // Initialize the framework (but don't navigate yet)
console.log('App: Creating framework instance...'); logger.debug('App: Creating framework instance...');
const app = window.app; const app = window.app;
// Components are loaded via script tags in order; no blocking wait required
// Create view models // Create view models
console.log('App: Creating view models...'); logger.debug('App: Creating view models...');
const clusterViewModel = new ClusterViewModel(); const clusterViewModel = new ClusterViewModel();
const firmwareViewModel = new FirmwareViewModel(); const firmwareViewModel = new FirmwareViewModel();
const topologyViewModel = new TopologyViewModel(); const topologyViewModel = new TopologyViewModel();
console.log('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel }); logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel });
// Connect firmware view model to cluster data // Connect firmware view model to cluster data
clusterViewModel.subscribe('members', (members) => { clusterViewModel.subscribe('members', (members) => {
console.log('App: Members subscription triggered:', members); logger.debug('App: Members subscription triggered:', members);
if (members && members.length > 0) { if (members && members.length > 0) {
// Extract node information for firmware view // Extract node information for firmware view
const nodes = members.map(member => ({ const nodes = members.map(member => ({
@@ -26,48 +28,39 @@ document.addEventListener('DOMContentLoaded', function() {
labels: member.labels || {} labels: member.labels || {}
})); }));
firmwareViewModel.updateAvailableNodes(nodes); firmwareViewModel.updateAvailableNodes(nodes);
console.log('App: Updated firmware view model with nodes:', nodes); logger.debug('App: Updated firmware view model with nodes:', nodes);
} else { } else {
firmwareViewModel.updateAvailableNodes([]); firmwareViewModel.updateAvailableNodes([]);
console.log('App: Cleared firmware view model nodes'); logger.debug('App: Cleared firmware view model nodes');
} }
}); });
// Register routes with their view models // Register routes with their view models
console.log('App: Registering routes...'); logger.debug('App: Registering routes...');
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel); app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel); app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel); app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
console.log('App: Routes registered and components pre-initialized'); logger.debug('App: Routes registered and components pre-initialized');
// Initialize cluster status component for header badge AFTER main components // Initialize cluster status component for header badge
// DISABLED - causes interference with main cluster functionality logger.debug('App: Initializing cluster status component...');
/*
console.log('App: Initializing cluster status component...');
const clusterStatusComponent = new ClusterStatusComponent( const clusterStatusComponent = new ClusterStatusComponent(
document.querySelector('.cluster-status'), document.querySelector('.cluster-status'),
clusterViewModel, clusterViewModel,
app.eventBus app.eventBus
); );
clusterStatusComponent.initialize(); clusterStatusComponent.mount();
console.log('App: Cluster status component initialized'); logger.debug('App: Cluster status component initialized');
*/
// Set up navigation event listeners // Set up navigation event listeners
console.log('App: Setting up navigation...'); logger.debug('App: Setting up navigation...');
app.setupNavigation(); app.setupNavigation();
// Set up cluster status updates (simple approach without component interference)
setupClusterStatusUpdates(clusterViewModel);
// Set up periodic updates for cluster view with state preservation
// setupPeriodicUpdates(); // Disabled automatic refresh
// Now navigate to the default route // Now navigate to the default route
console.log('App: Navigating to default route...'); logger.debug('App: Navigating to default route...');
app.navigateTo('cluster'); app.navigateTo('cluster');
console.log('=== SPORE UI Application initialization completed ==='); logger.debug('=== SPORE UI Application initialization completed ===');
}); });
// Burger menu toggle for mobile // Burger menu toggle for mobile
@@ -106,10 +99,10 @@ function setupPeriodicUpdates() {
// Use smart update if available, otherwise fall back to regular update // Use smart update if available, otherwise fall back to regular update
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') { if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
console.log('App: Performing smart update to preserve UI state...'); logger.debug('App: Performing smart update to preserve UI state...');
viewModel.smartUpdate(); viewModel.smartUpdate();
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') { } else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
console.log('App: Performing regular update...'); logger.debug('App: Performing regular update...');
viewModel.updateClusterMembers(); viewModel.updateClusterMembers();
} }
} }
@@ -126,104 +119,20 @@ function setupPeriodicUpdates() {
}, 10000); }, 10000);
} }
// Set up cluster status updates (simple approach without component interference)
function setupClusterStatusUpdates(clusterViewModel) {
// Set initial "discovering" state immediately
updateClusterStatusBadge(undefined, undefined, undefined);
// Force a fresh fetch and keep showing "discovering" until we get real data
let hasReceivedRealData = false;
// Subscribe to view model changes to update cluster status
clusterViewModel.subscribe('totalNodes', (totalNodes) => {
if (hasReceivedRealData) {
updateClusterStatusBadge(totalNodes, clusterViewModel.get('clientInitialized'), clusterViewModel.get('error'));
}
});
clusterViewModel.subscribe('clientInitialized', (clientInitialized) => {
if (hasReceivedRealData) {
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clientInitialized, clusterViewModel.get('error'));
}
});
clusterViewModel.subscribe('error', (error) => {
if (hasReceivedRealData) {
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clusterViewModel.get('clientInitialized'), error);
}
});
// Force a fresh fetch and only update status after we get real data
setTimeout(async () => {
try {
console.log('Cluster Status: Forcing fresh fetch from backend...');
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
console.log('Cluster Status: Got fresh data:', discoveryInfo);
// Now we have real data, mark it and update the status
hasReceivedRealData = true;
updateClusterStatusBadge(discoveryInfo.totalNodes, discoveryInfo.clientInitialized, null);
} catch (error) {
console.error('Cluster Status: Failed to fetch fresh data:', error);
hasReceivedRealData = true;
updateClusterStatusBadge(0, false, error.message);
}
}, 100); // Small delay to ensure view model is ready
}
function updateClusterStatusBadge(totalNodes, clientInitialized, error) {
const clusterStatusBadge = document.querySelector('.cluster-status');
if (!clusterStatusBadge) return;
let statusText, statusIcon, statusClass;
// Check if we're still in initial state (no real data yet)
const hasRealData = totalNodes !== undefined && clientInitialized !== undefined;
if (!hasRealData) {
statusText = 'Cluster Discovering...';
statusIcon = '🔍';
statusClass = 'cluster-status-discovering';
} else if (error || totalNodes === 0) {
// Show "Cluster Offline" for both errors and when no nodes are discovered
statusText = 'Cluster Offline';
statusIcon = '🔴';
statusClass = 'cluster-status-offline';
} else if (clientInitialized) {
statusText = 'Cluster Online';
statusIcon = '🟢';
statusClass = 'cluster-status-online';
} else {
statusText = 'Cluster Connecting';
statusIcon = '🟡';
statusClass = 'cluster-status-connecting';
}
// Update the badge
clusterStatusBadge.innerHTML = `${statusIcon} ${statusText}`;
// Remove all existing status classes
clusterStatusBadge.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error', 'cluster-status-discovering');
// Add the appropriate status class
clusterStatusBadge.classList.add(statusClass);
}
// Global error handler // Global error handler
window.addEventListener('error', function(event) { window.addEventListener('error', function(event) {
console.error('Global error:', event.error); logger.error('Global error:', event.error);
}); });
// Global unhandled promise rejection handler // Global unhandled promise rejection handler
window.addEventListener('unhandledrejection', function(event) { window.addEventListener('unhandledrejection', function(event) {
console.error('Unhandled promise rejection:', event.reason); logger.error('Unhandled promise rejection:', event.reason);
}); });
// Clean up on page unload // Clean up on page unload
window.addEventListener('beforeunload', function() { window.addEventListener('beforeunload', function() {
if (window.app) { if (window.app) {
console.log('App: Cleaning up cached components...'); logger.debug('App: Cleaning up cached components...');
window.app.cleanup(); window.app.cleanup();
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,628 @@
// Cluster Members Component with enhanced state preservation
class ClusterMembersComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('ClusterMembersComponent: Constructor called');
logger.debug('ClusterMembersComponent: Container:', container);
logger.debug('ClusterMembersComponent: Container ID:', container?.id);
logger.debug('ClusterMembersComponent: Container innerHTML:', container?.innerHTML);
// Track if we're in the middle of a render operation
this.renderInProgress = false;
this.lastRenderData = null;
// Ensure initial render happens even if no data
setTimeout(() => {
if (this.isMounted && !this.renderInProgress) {
logger.debug('ClusterMembersComponent: Performing initial render check');
this.render();
}
}, 200);
}
mount() {
logger.debug('ClusterMembersComponent: Starting mount...');
super.mount();
// Show loading state immediately when mounted
logger.debug('ClusterMembersComponent: Showing initial loading state');
this.showLoadingState();
// Set up loading timeout safeguard
this.setupLoadingTimeout();
logger.debug('ClusterMembersComponent: Mounted successfully');
}
// Setup loading timeout safeguard to prevent getting stuck in loading state
setupLoadingTimeout() {
this.loadingTimeout = setTimeout(() => {
const isLoading = this.viewModel.get('isLoading');
if (isLoading) {
logger.warn('ClusterMembersComponent: Loading timeout reached, forcing render check');
this.forceRenderCheck();
}
}, 10000); // 10 second timeout
}
// Force a render check when loading gets stuck
forceRenderCheck() {
logger.debug('ClusterMembersComponent: Force render check called');
const members = this.viewModel.get('members');
const error = this.viewModel.get('error');
const isLoading = this.viewModel.get('isLoading');
logger.debug('ClusterMembersComponent: Force render check state:', { members, error, isLoading });
if (error) {
this.showErrorState(error);
} else if (members && members.length > 0) {
this.renderMembers(members);
} else if (!isLoading) {
this.showEmptyState();
}
}
setupEventListeners() {
logger.debug('ClusterMembersComponent: Setting up event listeners...');
// Note: Refresh button is now handled by ClusterViewComponent
// since it's in the cluster header, not in the members container
}
setupViewModelListeners() {
logger.debug('ClusterMembersComponent: Setting up view model listeners...');
// Listen to cluster members changes with change detection
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
logger.debug('ClusterMembersComponent: View model listeners set up');
}
// Handle members update with state preservation
handleMembersUpdate(newMembers, previousMembers) {
logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers });
// Prevent multiple simultaneous renders
if (this.renderInProgress) {
logger.debug('ClusterMembersComponent: Render already in progress, skipping update');
return;
}
// Check if we're currently loading - if so, let the loading handler deal with it
const isLoading = this.viewModel.get('isLoading');
if (isLoading) {
logger.debug('ClusterMembersComponent: Currently loading, skipping members update (will be handled by loading completion)');
return;
}
// On first load (no previous members), always render
if (!previousMembers || !Array.isArray(previousMembers) || previousMembers.length === 0) {
logger.debug('ClusterMembersComponent: First load or no previous members, performing full render');
this.render();
return;
}
if (this.shouldPreserveState(newMembers, previousMembers)) {
// Perform partial update to preserve UI state
logger.debug('ClusterMembersComponent: Preserving state, performing partial update');
this.updateMembersPartially(newMembers, previousMembers);
} else {
// Full re-render if structure changed significantly
logger.debug('ClusterMembersComponent: Structure changed, performing full re-render');
this.render();
}
}
// Handle loading state update
handleLoadingUpdate(isLoading) {
logger.debug('ClusterMembersComponent: Loading state changed:', isLoading);
if (isLoading) {
logger.debug('ClusterMembersComponent: Showing loading state');
this.renderLoading(`
<div class="loading">
<div>Loading cluster members...</div>
</div>
`);
// Set up a loading completion check
this.checkLoadingCompletion();
} else {
logger.debug('ClusterMembersComponent: Loading completed, checking if we need to render');
// When loading completes, check if we have data to render
this.handleLoadingCompletion();
}
}
// Check if loading has completed and handle accordingly
handleLoadingCompletion() {
const members = this.viewModel.get('members');
const error = this.viewModel.get('error');
const isLoading = this.viewModel.get('isLoading');
logger.debug('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading });
if (error) {
logger.debug('ClusterMembersComponent: Loading completed with error, showing error state');
this.showErrorState(error);
} else if (members && members.length > 0) {
logger.debug('ClusterMembersComponent: Loading completed with data, rendering members');
this.renderMembers(members);
} else if (!isLoading) {
logger.debug('ClusterMembersComponent: Loading completed but no data, showing empty state');
this.showEmptyState();
}
}
// Set up a check to ensure loading completion is handled
checkLoadingCompletion() {
// Clear any existing completion check
if (this.loadingCompletionCheck) {
clearTimeout(this.loadingCompletionCheck);
}
// Set up a completion check that runs after a short delay
this.loadingCompletionCheck = setTimeout(() => {
const isLoading = this.viewModel.get('isLoading');
if (!isLoading) {
logger.debug('ClusterMembersComponent: Loading completion check triggered');
this.handleLoadingCompletion();
}
}, 1000); // Check after 1 second
}
// Handle error state update
handleErrorUpdate(error) {
if (error) {
this.showErrorState(error);
}
}
// Check if we should preserve UI state during update
shouldPreserveState(newMembers, previousMembers) {
if (!previousMembers || !Array.isArray(previousMembers)) return false;
if (!Array.isArray(newMembers)) return false;
// If member count changed, we need to re-render
if (newMembers.length !== previousMembers.length) return false;
// Check if member IPs are the same (same nodes)
const newIps = new Set(newMembers.map(m => m.ip));
const prevIps = new Set(previousMembers.map(m => m.ip));
// If IPs are the same, we can preserve state
return newIps.size === prevIps.size &&
[...newIps].every(ip => prevIps.has(ip));
}
// Check if we should skip rendering during view switches
shouldSkipRender() {
// Rely on lifecycle flags controlled by App
if (!this.isMounted || this.isPaused) {
logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render');
return true;
}
return false;
}
// Update members partially to preserve UI state
updateMembersPartially(newMembers, previousMembers) {
logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state');
// Build previous map by IP for stable diffs
const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m]));
newMembers.forEach((newMember) => {
const prevMember = prevByIp.get(newMember.ip);
if (prevMember && this.hasMemberChanged(newMember, prevMember)) {
this.updateMemberCard(newMember);
}
});
}
// Check if a specific member has changed
hasMemberChanged(newMember, prevMember) {
return newMember.status !== prevMember.status ||
newMember.latency !== prevMember.latency ||
newMember.hostname !== prevMember.hostname;
}
// Update a specific member card without re-rendering the entire component
updateMemberCard(member) {
const card = this.findElement(`[data-member-ip="${member.ip}"]`);
if (!card) return;
// Update status
const statusElement = card.querySelector('.member-status');
if (statusElement) {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
statusElement.className = `member-status ${statusClass}`;
statusElement.innerHTML = `${statusIcon}`;
}
// Update latency
const latencyElement = card.querySelector('.latency-value');
if (latencyElement) {
latencyElement.textContent = member.latency ? member.latency + 'ms' : 'N/A';
}
// Update hostname if changed
const hostnameElement = card.querySelector('.member-hostname');
if (hostnameElement && member.hostname !== hostnameElement.textContent) {
hostnameElement.textContent = member.hostname || 'Unknown Device';
}
}
render() {
if (this.renderInProgress) {
logger.debug('ClusterMembersComponent: Render already in progress, skipping');
return;
}
// Check if we should skip rendering during view switches
if (this.shouldSkipRender()) {
return;
}
this.renderInProgress = true;
try {
logger.debug('ClusterMembersComponent: render() called');
logger.debug('ClusterMembersComponent: Container element:', this.container);
logger.debug('ClusterMembersComponent: Is mounted:', this.isMounted);
const members = this.viewModel.get('members');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
logger.debug('ClusterMembersComponent: render data:', { members, isLoading, error });
if (isLoading) {
logger.debug('ClusterMembersComponent: Showing loading state');
this.showLoadingState();
return;
}
if (error) {
logger.debug('ClusterMembersComponent: Showing error state');
this.showErrorState(error);
return;
}
if (!members || members.length === 0) {
logger.debug('ClusterMembersComponent: Showing empty state');
this.showEmptyState();
return;
}
logger.debug('ClusterMembersComponent: Rendering members:', members);
this.renderMembers(members);
} finally {
this.renderInProgress = false;
}
}
// Show loading state
showLoadingState() {
logger.debug('ClusterMembersComponent: showLoadingState() called');
this.renderLoading(`
<div class="loading">
<div>Loading cluster members...</div>
</div>
`);
}
// Show error state
showErrorState(error) {
logger.debug('ClusterMembersComponent: showErrorState() called with error:', error);
this.renderError(`Error loading cluster members: ${error}`);
}
// Show empty state
showEmptyState() {
logger.debug('ClusterMembersComponent: showEmptyState() called');
this.renderEmpty(`
<div class="empty-state">
<div class="empty-state-icon">🌐</div>
<div>No cluster members found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
The cluster might be empty or not yet discovered
</div>
</div>
`);
}
renderMembers(members) {
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
const membersHTML = members.map(member => {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' : 'Offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
logger.debug('ClusterMembersComponent: Rendering member:', member);
return `
<div class="member-card" data-member-ip="${member.ip}">
<div class="member-header">
<div class="member-info">
<div class="member-row-1">
<div class="status-hostname-group">
<div class="member-status ${statusClass}">
${statusIcon}
</div>
<div class="member-hostname">${this.escapeHtml(member.hostname || 'Unknown Device')}</div>
</div>
<div class="member-ip">${this.escapeHtml(member.ip || 'No IP')}</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
</div>
${member.labels && Object.keys(member.labels).length ? `
<div class="member-row-2">
<div class="member-labels">
${Object.entries(member.labels).map(([key, value]) => `<span class=\"label-chip\">${this.escapeHtml(key)}: ${this.escapeHtml(value)}</span>`).join('')}
</div>
</div>
` : ''}
</div>
<div class="expand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
`;
}).join('');
logger.debug('ClusterMembersComponent: Setting HTML, length:', membersHTML.length);
this.setHTML('', membersHTML);
logger.debug('ClusterMembersComponent: HTML set, setting up member cards...');
this.setupMemberCards(members);
}
setupMemberCards(members) {
setTimeout(() => {
this.findAllElements('.member-card').forEach((card, index) => {
const expandIcon = card.querySelector('.expand-icon');
const memberDetails = card.querySelector('.member-details');
const memberIp = card.dataset.memberIp;
// Ensure all cards start collapsed by default
card.classList.remove('expanded');
if (expandIcon) {
expandIcon.classList.remove('expanded');
}
// Clear any previous content
memberDetails.innerHTML = '<div class="loading-details">Loading detailed information...</div>';
// Make the entire card clickable
this.addEventListener(card, 'click', async (e) => {
if (e.target === expandIcon) return;
const isExpanding = !card.classList.contains('expanded');
if (isExpanding) {
await this.expandCard(card, memberIp, memberDetails);
} else {
this.collapseCard(card, expandIcon);
}
});
// Keep the expand icon click handler for visual feedback
if (expandIcon) {
this.addEventListener(expandIcon, 'click', async (e) => {
e.stopPropagation();
const isExpanding = !card.classList.contains('expanded');
if (isExpanding) {
await this.expandCard(card, memberIp, memberDetails);
} else {
this.collapseCard(card, expandIcon);
}
});
}
});
}, 100);
}
async expandCard(card, memberIp, memberDetails) {
try {
// Create node details view model and component
const nodeDetailsVM = new NodeDetailsViewModel();
const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus);
// Load node details
await nodeDetailsVM.loadNodeDetails(memberIp);
// Mount the component
nodeDetailsComponent.mount();
// Update UI
card.classList.add('expanded');
const expandIcon = card.querySelector('.expand-icon');
if (expandIcon) {
expandIcon.classList.add('expanded');
}
} catch (error) {
logger.error('Failed to expand card:', error);
memberDetails.innerHTML = `
<div class="error">
<strong>Error loading node details:</strong><br>
${error.message}
</div>
`;
}
}
collapseCard(card, expandIcon) {
card.classList.remove('expanded');
if (expandIcon) {
expandIcon.classList.remove('expanded');
}
}
setupTabs(container) {
super.setupTabs(container, {
onChange: (targetTab) => {
const memberCard = container.closest('.member-card');
if (memberCard) {
const memberIp = memberCard.dataset.memberIp;
this.viewModel.storeActiveTab(memberIp, targetTab);
}
}
});
}
// Restore active tab state
restoreActiveTab(container, activeTab) {
const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content');
// Remove active class from all buttons and contents
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to the restored tab
const activeButton = container.querySelector(`[data-tab="${activeTab}"]`);
const activeContent = container.querySelector(`#${activeTab}-tab`);
if (activeButton) activeButton.classList.add('active');
if (activeContent) activeContent.classList.add('active');
}
// Note: handleRefresh method has been moved to ClusterViewComponent
// since the refresh button is in the cluster header, not in the members container
// Debug method to check component state
debugState() {
const members = this.viewModel.get('members');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
const expandedCards = this.viewModel.get('expandedCards');
const activeTabs = this.viewModel.get('activeTabs');
logger.debug('ClusterMembersComponent: Debug State:', {
isMounted: this.isMounted,
container: this.container,
members: members,
membersCount: members?.length || 0,
isLoading: isLoading,
error: error,
expandedCardsCount: expandedCards?.size || 0,
activeTabsCount: activeTabs?.size || 0,
loadingTimeout: this.loadingTimeout
});
return { members, isLoading, error, expandedCards, activeTabs };
}
// Manual refresh method that bypasses potential state conflicts
async manualRefresh() {
logger.debug('ClusterMembersComponent: Manual refresh called');
try {
// Clear any existing loading state
this.viewModel.set('isLoading', false);
this.viewModel.set('error', null);
// Force a fresh data load
await this.viewModel.updateClusterMembers();
logger.debug('ClusterMembersComponent: Manual refresh completed');
} catch (error) {
logger.error('ClusterMembersComponent: Manual refresh failed:', error);
this.showErrorState(error.message);
}
}
unmount() {
if (!this.isMounted) return;
this.isMounted = false;
// Clear any pending timeouts
if (this.loadingTimeout) {
clearTimeout(this.loadingTimeout);
this.loadingTimeout = null;
}
if (this.loadingCompletionCheck) {
clearTimeout(this.loadingCompletionCheck);
this.loadingCompletionCheck = null;
}
// Clear any pending render operations
this.renderInProgress = false;
this.cleanupEventListeners();
this.cleanupViewModelListeners();
logger.debug(`${this.constructor.name} unmounted`);
}
// Override pause method to handle timeouts and operations
onPause() {
logger.debug('ClusterMembersComponent: Pausing...');
// Clear any pending timeouts
if (this.loadingTimeout) {
clearTimeout(this.loadingTimeout);
this.loadingTimeout = null;
}
if (this.loadingCompletionCheck) {
clearTimeout(this.loadingCompletionCheck);
this.loadingCompletionCheck = null;
}
// Mark as paused to prevent new operations
this.isPaused = true;
}
// Override resume method to restore functionality
onResume() {
logger.debug('ClusterMembersComponent: Resuming...');
this.isPaused = false;
// Re-setup loading timeout if needed
if (!this.loadingTimeout) {
this.setupLoadingTimeout();
}
// Check if we need to handle any pending operations
this.checkPendingOperations();
}
// Check for any operations that need to be handled after resume
checkPendingOperations() {
const isLoading = this.viewModel.get('isLoading');
const members = this.viewModel.get('members');
// If we were loading and it completed while paused, handle the completion
if (!isLoading && members && members.length > 0) {
logger.debug('ClusterMembersComponent: Handling pending loading completion after resume');
this.handleLoadingCompletion();
}
}
// Override to determine if re-render is needed on resume
shouldRenderOnResume() {
// Don't re-render on resume - maintain current state
return false;
}
}
window.ClusterMembersComponent = ClusterMembersComponent;

View File

@@ -0,0 +1,50 @@
// Cluster Status Component for header badge
class ClusterStatusComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupViewModelListeners() {
// Subscribe to properties that affect cluster status
this.subscribeToProperty('totalNodes', this.render.bind(this));
this.subscribeToProperty('clientInitialized', this.render.bind(this));
this.subscribeToProperty('error', this.render.bind(this));
}
render() {
const totalNodes = this.viewModel.get('totalNodes');
const clientInitialized = this.viewModel.get('clientInitialized');
const error = this.viewModel.get('error');
let statusText, statusIcon, statusClass;
if (error) {
statusText = 'Cluster Error';
statusIcon = '❌';
statusClass = 'cluster-status-error';
} else if (totalNodes === 0) {
statusText = 'Cluster Offline';
statusIcon = '🔴';
statusClass = 'cluster-status-offline';
} else if (clientInitialized) {
statusText = 'Cluster Online';
statusIcon = '🟢';
statusClass = 'cluster-status-online';
} else {
statusText = 'Cluster Connecting';
statusIcon = '🟡';
statusClass = 'cluster-status-connecting';
}
// Update the cluster status badge using the container passed to this component
if (this.container) {
this.container.innerHTML = `${statusIcon} ${statusText}`;
// Remove all existing status classes
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
// Add the appropriate status class
this.container.classList.add(statusClass);
}
}
}

View File

@@ -0,0 +1,209 @@
// Cluster View Component
class ClusterViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('ClusterViewComponent: Constructor called');
logger.debug('ClusterViewComponent: Container:', container);
logger.debug('ClusterViewComponent: Container ID:', container?.id);
// Find elements for sub-components
const primaryNodeContainer = this.findElement('.primary-node-info');
const clusterMembersContainer = this.findElement('#cluster-members-container');
logger.debug('ClusterViewComponent: Primary node container:', primaryNodeContainer);
logger.debug('ClusterViewComponent: Cluster members container:', clusterMembersContainer);
logger.debug('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id);
logger.debug('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML);
// Create sub-components
this.primaryNodeComponent = new PrimaryNodeComponent(
primaryNodeContainer,
viewModel,
eventBus
);
this.clusterMembersComponent = new ClusterMembersComponent(
clusterMembersContainer,
viewModel,
eventBus
);
logger.debug('ClusterViewComponent: Sub-components created');
// Track if we've already loaded data to prevent unnecessary reloads
this.dataLoaded = false;
}
mount() {
logger.debug('ClusterViewComponent: Mounting...');
super.mount();
logger.debug('ClusterViewComponent: Mounting sub-components...');
// Mount sub-components
this.primaryNodeComponent.mount();
this.clusterMembersComponent.mount();
// Set up refresh button event listener (since it's in the cluster header, not in the members container)
this.setupRefreshButton();
// Only load data if we haven't already or if the view model is empty
const members = this.viewModel.get('members');
const shouldLoadData = true; // always perform initial refresh quickly
if (shouldLoadData) {
logger.debug('ClusterViewComponent: Starting initial data load...');
// Initial data load - ensure it happens after mounting
// Trigger immediately to reduce perceived startup latency
this.viewModel.updateClusterMembers().then(() => {
this.dataLoaded = true;
}).catch(error => {
logger.error('ClusterViewComponent: Failed to load initial data:', error);
});
} else {
logger.debug('ClusterViewComponent: Data already loaded, skipping initial load');
}
// Set up periodic updates
// this.setupPeriodicUpdates(); // Disabled automatic refresh
logger.debug('ClusterViewComponent: Mounted successfully');
}
setupRefreshButton() {
logger.debug('ClusterViewComponent: Setting up refresh button...');
const refreshBtn = this.findElement('.refresh-btn');
logger.debug('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn);
if (refreshBtn) {
logger.debug('ClusterViewComponent: Adding click event listener to refresh button');
this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
logger.debug('ClusterViewComponent: Event listener added successfully');
} else {
logger.error('ClusterViewComponent: Refresh button not found!');
logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
}
}
async handleRefresh() {
logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...');
// Get the refresh button and show loading state
const refreshBtn = this.findElement('.refresh-btn');
logger.debug('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn);
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
logger.debug('ClusterViewComponent: Original button text:', originalText);
refreshBtn.innerHTML = `
<svg class="refresh-icon spinning" 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>
Refreshing...
`;
refreshBtn.disabled = true;
try {
logger.debug('ClusterViewComponent: Starting cluster members update...');
// Always perform a full refresh when user clicks refresh button
await this.viewModel.updateClusterMembers();
logger.debug('ClusterViewComponent: Cluster members update completed successfully');
} catch (error) {
logger.error('ClusterViewComponent: Error during refresh:', error);
// Show error state
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
}
} finally {
logger.debug('ClusterViewComponent: Restoring button state...');
// Restore button state
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
}
} else {
logger.warn('ClusterViewComponent: Refresh button not found, using fallback refresh');
// Fallback if button not found
try {
await this.viewModel.updateClusterMembers();
} catch (error) {
logger.error('ClusterViewComponent: Fallback refresh failed:', error);
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
}
}
}
}
unmount() {
logger.debug('ClusterViewComponent: Unmounting...');
// Unmount sub-components
if (this.primaryNodeComponent) {
this.primaryNodeComponent.unmount();
}
if (this.clusterMembersComponent) {
this.clusterMembersComponent.unmount();
}
// Clear intervals
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
super.unmount();
logger.debug('ClusterViewComponent: Unmounted');
}
// Override pause method to handle sub-components
onPause() {
logger.debug('ClusterViewComponent: Pausing...');
// Pause sub-components
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
this.primaryNodeComponent.pause();
}
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
this.clusterMembersComponent.pause();
}
// Clear any active intervals
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}
// Override resume method to handle sub-components
onResume() {
logger.debug('ClusterViewComponent: Resuming...');
// Resume sub-components
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
this.primaryNodeComponent.resume();
}
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
this.clusterMembersComponent.resume();
}
// Restart periodic updates if needed
// this.setupPeriodicUpdates(); // Disabled automatic refresh
}
// Override to determine if re-render is needed on resume
shouldRenderOnResume() {
// Don't re-render on resume - the component should maintain its state
return false;
}
setupPeriodicUpdates() {
// Update primary node display every 10 seconds
this.updateInterval = setInterval(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 10000);
}
}
window.ClusterViewComponent = ClusterViewComponent;

View File

@@ -0,0 +1,16 @@
(function(){
// Simple readiness flag once all component constructors are present
function allReady(){
return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent);
}
window.waitForComponentsReady = function(timeoutMs = 5000){
return new Promise((resolve, reject) => {
const start = Date.now();
(function check(){
if (allReady()) return resolve(true);
if (Date.now() - start > timeoutMs) return reject(new Error('Components did not load in time'));
setTimeout(check, 25);
})();
});
};
})();

View File

@@ -0,0 +1,698 @@
// Firmware Component
class FirmwareComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('FirmwareComponent: Constructor called');
logger.debug('FirmwareComponent: Container:', container);
logger.debug('FirmwareComponent: Container ID:', container?.id);
// Check if the dropdown exists in the container
if (container) {
const dropdown = container.querySelector('#specific-node-select');
logger.debug('FirmwareComponent: Dropdown found in constructor:', !!dropdown);
if (dropdown) {
logger.debug('FirmwareComponent: Dropdown tagName:', dropdown.tagName);
logger.debug('FirmwareComponent: Dropdown id:', dropdown.id);
}
}
}
setupEventListeners() {
// Setup global firmware file input
const globalFirmwareFile = this.findElement('#global-firmware-file');
if (globalFirmwareFile) {
this.addEventListener(globalFirmwareFile, 'change', this.handleFileSelect.bind(this));
}
// Setup target selection
const targetRadios = this.findAllElements('input[name="target-type"]');
targetRadios.forEach(radio => {
this.addEventListener(radio, 'change', this.handleTargetChange.bind(this));
});
// Setup specific node select change handler
const specificNodeSelect = this.findElement('#specific-node-select');
logger.debug('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect);
if (specificNodeSelect) {
logger.debug('FirmwareComponent: specificNodeSelect element:', specificNodeSelect);
logger.debug('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName);
logger.debug('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id);
// Store the bound handler as an instance property
this._boundNodeSelectHandler = this.handleNodeSelect.bind(this);
this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler);
logger.debug('FirmwareComponent: Event listener added to specificNodeSelect');
} else {
logger.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners');
}
// Setup label select change handler (single-select add-to-chips)
const labelSelect = this.findElement('#label-select');
if (labelSelect) {
this._boundLabelSelectHandler = (e) => {
const value = e.target.value;
if (!value) return;
const current = this.viewModel.get('selectedLabels') || [];
if (!current.includes(value)) {
this.viewModel.setSelectedLabels([...current, value]);
}
// Reset select back to placeholder
e.target.value = '';
this.renderSelectedLabelChips();
this.updateAffectedNodesPreview();
this.updateDeployButton();
};
this.addEventListener(labelSelect, 'change', this._boundLabelSelectHandler);
}
// Setup deploy button
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this));
}
}
setupViewModelListeners() {
this.subscribeToProperty('selectedFile', () => {
this.updateFileInfo();
this.updateDeployButton();
});
this.subscribeToProperty('targetType', () => {
this.updateTargetVisibility();
this.updateDeployButton();
this.updateAffectedNodesPreview();
});
this.subscribeToProperty('specificNode', this.updateDeployButton.bind(this));
this.subscribeToProperty('availableNodes', () => {
this.populateNodeSelect();
this.populateLabelSelect();
this.updateDeployButton();
this.updateAffectedNodesPreview();
});
this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this));
this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this));
this.subscribeToProperty('isUploading', this.updateUploadState.bind(this));
this.subscribeToProperty('selectedLabels', () => {
this.populateLabelSelect();
this.updateAffectedNodesPreview();
this.updateDeployButton();
});
}
mount() {
super.mount();
logger.debug('FirmwareComponent: Mounting...');
// Check if the dropdown exists when mounted
const dropdown = this.findElement('#specific-node-select');
logger.debug('FirmwareComponent: Mount - dropdown found:', !!dropdown);
if (dropdown) {
logger.debug('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName);
logger.debug('FirmwareComponent: Mount - dropdown id:', dropdown.id);
logger.debug('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML);
}
// Initialize target visibility and label list on first mount
try {
this.updateTargetVisibility();
this.populateLabelSelect();
this.updateAffectedNodesPreview();
} catch (e) {
logger.warn('FirmwareComponent: Initialization after mount failed:', e);
}
logger.debug('FirmwareComponent: Mounted successfully');
}
render() {
// Initial render is handled by the HTML template
this.updateDeployButton();
}
handleFileSelect(event) {
const file = event.target.files[0];
this.viewModel.setSelectedFile(file);
}
handleTargetChange(event) {
const targetType = event.target.value;
this.viewModel.setTargetType(targetType);
}
handleNodeSelect(event) {
const nodeIp = event.target.value;
logger.debug('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp);
logger.debug('Event:', event);
logger.debug('Event target:', event.target);
logger.debug('Event target value:', event.target.value);
this.viewModel.setSpecificNode(nodeIp);
// Also update the deploy button state
this.updateDeployButton();
}
async handleDeploy() {
const file = this.viewModel.get('selectedFile');
const targetType = this.viewModel.get('targetType');
const specificNode = this.viewModel.get('specificNode');
if (!file) {
alert('Please select a firmware file first.');
return;
}
if (targetType === 'specific' && !specificNode) {
alert('Please select a specific node to update.');
return;
}
try {
this.viewModel.startUpload();
if (targetType === 'all') {
await this.uploadToAllNodes(file);
} else if (targetType === 'specific') {
await this.uploadToSpecificNode(file, specificNode);
} else if (targetType === 'labels') {
await this.uploadToLabelFilteredNodes(file);
}
// Reset interface after successful upload
this.viewModel.resetUploadState();
} catch (error) {
logger.error('Firmware deployment failed:', error);
alert(`Deployment failed: ${error.message}`);
} finally {
this.viewModel.completeUpload();
}
}
async uploadToAllNodes(file) {
try {
// Get current cluster members
const response = await window.apiClient.getClusterMembers();
const nodes = response.members || [];
if (nodes.length === 0) {
alert('No nodes available for firmware update.');
return;
}
const confirmed = confirm(`Upload firmware to all ${nodes.length} nodes? This will update: ${nodes.map(n => n.hostname || n.ip).join(', ')}`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, nodes);
// Start batch upload
const results = await this.performBatchUpload(file, nodes);
// Display results
this.displayUploadResults(results);
} catch (error) {
logger.error('Failed to upload firmware to all nodes:', error);
throw error;
}
}
async uploadToSpecificNode(file, nodeIp) {
try {
const confirmed = confirm(`Upload firmware to node ${nodeIp}?`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]);
// Update progress to show starting
this.updateNodeProgress(1, 1, nodeIp, 'Uploading...');
// Perform single node upload
const result = await this.performSingleUpload(file, nodeIp);
// Update progress to show completion
this.updateNodeProgress(1, 1, nodeIp, 'Completed');
this.updateOverallProgress(1, 1);
// Display results
this.displayUploadResults([result]);
} catch (error) {
logger.error(`Failed to upload firmware to node ${nodeIp}:`, error);
// Update progress to show failure
this.updateNodeProgress(1, 1, nodeIp, 'Failed');
this.updateOverallProgress(0, 1);
// Display error results
const errorResult = {
nodeIp: nodeIp,
hostname: nodeIp,
success: false,
error: error.message,
timestamp: new Date().toISOString()
};
this.displayUploadResults([errorResult]);
throw error;
}
}
async uploadToLabelFilteredNodes(file) {
try {
const nodes = this.viewModel.getAffectedNodesByLabels();
if (!nodes || nodes.length === 0) {
alert('No nodes match the selected labels.');
return;
}
const labels = this.viewModel.get('selectedLabels') || [];
const confirmed = confirm(`Upload firmware to ${nodes.length} node(s) matching labels (${labels.join(', ')})?`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, nodes);
// Start batch upload
const results = await this.performBatchUpload(file, nodes);
// Display results
this.displayUploadResults(results);
} catch (error) {
logger.error('Failed to upload firmware to label-filtered nodes:', error);
throw error;
}
}
async performBatchUpload(file, nodes) {
const results = [];
const totalNodes = nodes.length;
let successfulUploads = 0;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const nodeIp = node.ip;
try {
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...');
// Upload to this node
const result = await this.performSingleUpload(file, nodeIp);
results.push(result);
successfulUploads++;
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed');
this.updateOverallProgress(successfulUploads, totalNodes);
} catch (error) {
logger.error(`Failed to upload to node ${nodeIp}:`, error);
const errorResult = {
nodeIp: nodeIp,
hostname: node.hostname || nodeIp,
success: false,
error: error.message,
timestamp: new Date().toISOString()
};
results.push(errorResult);
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed');
this.updateOverallProgress(successfulUploads, totalNodes);
}
// Small delay between uploads
if (i < nodes.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
async performSingleUpload(file, nodeIp) {
try {
const result = await window.apiClient.uploadFirmware(file, nodeIp);
return {
nodeIp: nodeIp,
hostname: nodeIp,
success: true,
result: result,
timestamp: new Date().toISOString()
};
} catch (error) {
throw new Error(`Upload to ${nodeIp} failed: ${error.message}`);
}
}
showUploadProgress(file, nodes) {
const container = this.findElement('#firmware-nodes-list');
const progressHTML = `
<div class="firmware-upload-progress" id="firmware-upload-progress">
<div class="progress-header">
<h3>📤 Firmware Upload Progress</h3>
<div class="progress-info">
<span>File: ${file.name}</span>
<span>Size: ${(file.size / 1024).toFixed(1)}KB</span>
<span>Targets: ${nodes.length} node(s)</span>
</div>
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar" id="overall-progress-bar" style="width: 0%; background-color: #fbbf24;"></div>
</div>
<span class="progress-text">0/${nodes.length} Successful (0%)</span>
</div>
<div class="progress-summary" id="progress-summary">
<span>Status: Preparing upload...</span>
</div>
</div>
<div class="progress-list" id="progress-list">
${nodes.map(node => `
<div class="progress-item" data-node-ip="${node.ip}">
<div class="progress-node-info">
<span class="node-name">${node.hostname || node.ip}</span>
<span class="node-ip">${node.ip}</span>
</div>
<div class="progress-status">Pending...</div>
<div class="progress-time" id="time-${node.ip}"></div>
</div>
`).join('')}
</div>
</div>
`;
container.innerHTML = progressHTML;
// Initialize progress for single-node uploads
if (nodes.length === 1) {
const node = nodes[0];
this.updateNodeProgress(1, 1, node.ip, 'Pending...');
}
}
updateNodeProgress(current, total, nodeIp, status) {
const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`);
if (progressItem) {
const statusElement = progressItem.querySelector('.progress-status');
const timeElement = progressItem.querySelector('.progress-time');
if (statusElement) {
statusElement.textContent = status;
// Add status-specific styling
statusElement.className = 'progress-status';
if (status === 'Completed') {
statusElement.classList.add('success');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString();
}
} else if (status === 'Failed') {
statusElement.classList.add('error');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString();
}
} else if (status === 'Uploading...') {
statusElement.classList.add('uploading');
if (timeElement) {
timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString();
}
}
}
}
}
updateOverallProgress(successfulUploads, totalNodes) {
const progressBar = this.findElement('#overall-progress-bar');
const progressText = this.findElement('.progress-text');
if (progressBar && progressText) {
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
progressBar.style.width = `${successPercentage}%`;
progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`;
// Update progress bar color based on completion
if (successPercentage === 100) {
progressBar.style.backgroundColor = '#4ade80';
} else if (successPercentage > 50) {
progressBar.style.backgroundColor = '#60a5fa';
} else {
progressBar.style.backgroundColor = '#fbbf24';
}
// Update progress summary for single-node uploads
const progressSummary = this.findElement('#progress-summary');
if (progressSummary && totalNodes === 1) {
if (successfulUploads === 1) {
progressSummary.innerHTML = '<span>Status: Upload completed successfully</span>';
} else if (successfulUploads === 0) {
progressSummary.innerHTML = '<span>Status: Upload failed</span>';
}
}
}
}
displayUploadResults(results) {
const progressHeader = this.findElement('.progress-header h3');
const progressSummary = this.findElement('#progress-summary');
if (progressHeader && progressSummary) {
const successCount = results.filter(r => r.success).length;
const totalCount = results.length;
const successRate = Math.round((successCount / totalCount) * 100);
if (totalCount === 1) {
// Single node upload
if (successCount === 1) {
progressHeader.textContent = `📤 Firmware Upload Complete`;
progressSummary.innerHTML = `<span>✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
progressHeader.textContent = `📤 Firmware Upload Failed`;
progressSummary.innerHTML = `<span>❌ Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}</span>`;
}
} else if (successCount === totalCount) {
// Multi-node upload - all successful
progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
// Multi-node upload - some failed
progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}</span>`;
}
}
}
updateFileInfo() {
const file = this.viewModel.get('selectedFile');
const fileInfo = this.findElement('#file-info');
const deployBtn = this.findElement('#deploy-btn');
if (file) {
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
fileInfo.classList.add('has-file');
} else {
fileInfo.textContent = 'No file selected';
fileInfo.classList.remove('has-file');
}
this.updateDeployButton();
}
updateTargetVisibility() {
const targetType = this.viewModel.get('targetType');
const specificNodeSelect = this.findElement('#specific-node-select');
const labelSelect = this.findElement('#label-select');
logger.debug('FirmwareComponent: updateTargetVisibility called with targetType:', targetType);
if (targetType === 'specific') {
if (specificNodeSelect) { specificNodeSelect.style.display = 'inline-block'; }
if (labelSelect) { labelSelect.style.display = 'none'; }
this.populateNodeSelect();
} else if (targetType === 'labels') {
if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; }
if (labelSelect) {
labelSelect.style.display = 'inline-block';
this.populateLabelSelect();
}
} else {
if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; }
if (labelSelect) { labelSelect.style.display = 'none'; }
}
this.updateDeployButton();
}
// Note: handleNodeSelect is already defined above and handles the actual node selection
// This duplicate method was causing the issue - removing it
updateDeployButton() {
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
deployBtn.disabled = !this.viewModel.isDeployEnabled();
}
}
populateNodeSelect() {
const select = this.findElement('#specific-node-select');
if (!select) {
logger.warn('FirmwareComponent: populateNodeSelect - select element not found');
return;
}
if (select.tagName !== 'SELECT') {
logger.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName);
return;
}
logger.debug('FirmwareComponent: populateNodeSelect called');
logger.debug('FirmwareComponent: Select element:', select);
logger.debug('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes'));
// Clear existing options
select.innerHTML = '<option value="">Select a node...</option>';
// Get available nodes from the view model
const availableNodes = this.viewModel.get('availableNodes');
if (!availableNodes || availableNodes.length === 0) {
// No nodes available
const option = document.createElement('option');
option.value = "";
option.textContent = "No nodes available";
option.disabled = true;
select.appendChild(option);
return;
}
availableNodes.forEach(node => {
const option = document.createElement('option');
option.value = node.ip;
option.textContent = `${node.hostname} (${node.ip})`;
select.appendChild(option);
});
// Ensure event listener is still bound after repopulating
this.ensureNodeSelectListener(select);
logger.debug('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes');
}
// Ensure the node select change listener is properly bound
ensureNodeSelectListener(select) {
if (!select) return;
// Store the bound handler as an instance property to avoid binding issues
if (!this._boundNodeSelectHandler) {
this._boundNodeSelectHandler = this.handleNodeSelect.bind(this);
}
// Remove any existing listeners and add the bound one
select.removeEventListener('change', this._boundNodeSelectHandler);
select.addEventListener('change', this._boundNodeSelectHandler);
logger.debug('FirmwareComponent: Node select event listener ensured');
}
updateUploadProgress() {
// This will be implemented when we add upload progress tracking
}
updateUploadResults() {
// This will be implemented when we add upload results display
}
updateUploadState() {
const isUploading = this.viewModel.get('isUploading');
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
deployBtn.disabled = isUploading;
if (isUploading) {
deployBtn.classList.add('loading');
deployBtn.textContent = '⏳ Deploying...';
} else {
deployBtn.classList.remove('loading');
deployBtn.textContent = '🚀 Deploy';
}
}
this.updateDeployButton();
}
populateLabelSelect() {
const select = this.findElement('#label-select');
if (!select) return;
const labels = this.viewModel.get('availableLabels') || [];
const selected = new Set(this.viewModel.get('selectedLabels') || []);
const options = ['<option value="">Select a label...</option>']
.concat(labels.filter(l => !selected.has(l)).map(l => `<option value="${l}">${l}</option>`));
select.innerHTML = options.join('');
// Ensure change listener remains bound
if (this._boundLabelSelectHandler) {
select.removeEventListener('change', this._boundLabelSelectHandler);
select.addEventListener('change', this._boundLabelSelectHandler);
}
this.renderSelectedLabelChips();
}
renderSelectedLabelChips() {
const container = this.findElement('#selected-labels-container');
if (!container) return;
const selected = this.viewModel.get('selectedLabels') || [];
if (selected.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = selected.map(l => `
<span class="label-chip removable" data-label="${l}">
${l}
<button class="chip-remove" data-label="${l}" title="Remove">×</button>
</span>
`).join('');
Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => {
this.addEventListener(btn, 'click', (e) => {
e.stopPropagation();
const label = btn.getAttribute('data-label');
const current = this.viewModel.get('selectedLabels') || [];
this.viewModel.setSelectedLabels(current.filter(x => x !== label));
this.populateLabelSelect();
this.updateAffectedNodesPreview();
this.updateDeployButton();
});
});
}
updateAffectedNodesPreview() {
const container = this.findElement('#firmware-nodes-list');
if (!container) return;
if (this.viewModel.get('targetType') !== 'labels') {
container.innerHTML = '';
return;
}
const nodes = this.viewModel.getAffectedNodesByLabels();
if (!nodes.length) {
container.innerHTML = `<div class="empty-state"><div>No nodes match the selected labels</div></div>`;
return;
}
const html = `
<div class="affected-nodes">
<div class="progress-header"><h3>🎯 Affected Nodes (${nodes.length})</h3></div>
<div class="progress-list">
${nodes.map(n => `
<div class="progress-item" data-node-ip="${n.ip}">
<div class="progress-node-info"><span class="node-name">${n.hostname || n.ip}</span><span class="node-ip">${n.ip}</span></div>
</div>
`).join('')}
</div>
</div>`;
container.innerHTML = html;
}
}
window.FirmwareComponent = FirmwareComponent;

View File

@@ -0,0 +1,82 @@
// Firmware View Component
class FirmwareViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('FirmwareViewComponent: Constructor called');
logger.debug('FirmwareViewComponent: Container:', container);
const firmwareContainer = this.findElement('#firmware-container');
logger.debug('FirmwareViewComponent: Firmware container found:', !!firmwareContainer);
this.firmwareComponent = new FirmwareComponent(
firmwareContainer,
viewModel,
eventBus
);
logger.debug('FirmwareViewComponent: FirmwareComponent created');
}
mount() {
super.mount();
logger.debug('FirmwareViewComponent: Mounting...');
// Mount sub-component
this.firmwareComponent.mount();
// Update available nodes
this.updateAvailableNodes();
logger.debug('FirmwareViewComponent: Mounted successfully');
}
unmount() {
// Unmount sub-component
if (this.firmwareComponent) {
this.firmwareComponent.unmount();
}
super.unmount();
}
// Override pause method to handle sub-components
onPause() {
logger.debug('FirmwareViewComponent: Pausing...');
// Pause sub-component
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
this.firmwareComponent.pause();
}
}
// Override resume method to handle sub-components
onResume() {
logger.debug('FirmwareViewComponent: Resuming...');
// Resume sub-component
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
this.firmwareComponent.resume();
}
}
// Override to determine if re-render is needed on resume
shouldRenderOnResume() {
// Don't re-render on resume - maintain current state
return false;
}
async updateAvailableNodes() {
try {
logger.debug('FirmwareViewComponent: updateAvailableNodes called');
const response = await window.apiClient.getClusterMembers();
const nodes = response.members || [];
logger.debug('FirmwareViewComponent: Got nodes:', nodes);
this.viewModel.updateAvailableNodes(nodes);
logger.debug('FirmwareViewComponent: Available nodes updated in view model');
} catch (error) {
logger.error('Failed to update available nodes:', error);
}
}
}

View File

@@ -0,0 +1,529 @@
// Node Details Component with enhanced state preservation
class NodeDetailsComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupViewModelListeners() {
this.subscribeToProperty('nodeStatus', this.handleNodeStatusUpdate.bind(this));
this.subscribeToProperty('tasks', this.handleTasksUpdate.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this));
}
// Handle node status update with state preservation
handleNodeStatusUpdate(newStatus, previousStatus) {
if (newStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities'));
}
}
// Handle tasks update with state preservation
handleTasksUpdate(newTasks, previousTasks) {
const nodeStatus = this.viewModel.get('nodeStatus');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities'));
}
}
// Handle loading state update
handleLoadingUpdate(isLoading) {
if (isLoading) {
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
}
}
// Handle error state update
handleErrorUpdate(error) {
if (error) {
this.renderError(`Error loading node details: ${error}`);
}
}
// Handle active tab update
handleActiveTabUpdate(newTab, previousTab) {
// Update tab UI without full re-render
this.updateActiveTab(newTab, previousTab);
}
// Handle capabilities update with state preservation
handleCapabilitiesUpdate(newCapabilities, previousCapabilities) {
const nodeStatus = this.viewModel.get('nodeStatus');
const tasks = this.viewModel.get('tasks');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, tasks, newCapabilities);
}
}
render() {
const nodeStatus = this.viewModel.get('nodeStatus');
const tasks = this.viewModel.get('tasks');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
const capabilities = this.viewModel.get('capabilities');
if (isLoading) {
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
return;
}
if (error) {
this.renderError(`Error loading node details: ${error}`);
return;
}
if (!nodeStatus) {
this.renderEmpty('<div class="loading-details">No node status available</div>');
return;
}
this.renderNodeDetails(nodeStatus, tasks, capabilities);
}
renderNodeDetails(nodeStatus, tasks, capabilities) {
// Use persisted active tab from the view model, default to 'status'
const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status';
logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab);
const html = `
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-button ${activeTab === 'status' ? 'active' : ''}" data-tab="status">Status</button>
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
<button class="tab-button ${activeTab === 'capabilities' ? 'active' : ''}" data-tab="capabilities">Capabilities</button>
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
</div>
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
<div class="detail-row">
<span class="detail-label">Free Heap:</span>
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
</div>
<div class="detail-row">
<span class="detail-label">Chip ID:</span>
<span class="detail-value">${nodeStatus.chipId}</span>
</div>
<div class="detail-row">
<span class="detail-label">SDK Version:</span>
<span class="detail-value">${nodeStatus.sdkVersion}</span>
</div>
<div class="detail-row">
<span class="detail-label">CPU Frequency:</span>
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
</div>
<div class="detail-row">
<span class="detail-label">Flash Size:</span>
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
</div>
</div>
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
${nodeStatus.api ? nodeStatus.api.map(endpoint =>
`<div class="endpoint-item">${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}</div>`
).join('') : '<div class="endpoint-item">No API endpoints available</div>'}
</div>
<div class="tab-content ${activeTab === 'capabilities' ? 'active' : ''}" id="capabilities-tab">
${this.renderCapabilitiesTab(capabilities)}
</div>
<div class="tab-content ${activeTab === 'tasks' ? 'active' : ''}" id="tasks-tab">
${this.renderTasksTab(tasks)}
</div>
<div class="tab-content ${activeTab === 'firmware' ? 'active' : ''}" id="firmware-tab">
${this.renderFirmwareTab()}
</div>
</div>
`;
this.setHTML('', html);
this.setupTabs();
// Restore last active tab from view model if available
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
if (restored) {
this.setActiveTab(restored);
}
this.setupFirmwareUpload();
}
renderCapabilitiesTab(capabilities) {
if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) {
return `
<div class="no-capabilities">
<div>🧩 No capabilities reported</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any capabilities</div>
</div>
`;
}
// Sort endpoints by URI (name), then by method for stable ordering
const endpoints = [...capabilities.endpoints].sort((a, b) => {
const aUri = String(a.uri || '').toLowerCase();
const bUri = String(b.uri || '').toLowerCase();
if (aUri < bUri) return -1;
if (aUri > bUri) return 1;
const aMethod = String(a.method || '').toLowerCase();
const bMethod = String(b.method || '').toLowerCase();
return aMethod.localeCompare(bMethod);
});
const total = endpoints.length;
// Preserve selection based on a stable key of method+uri if available
const selectedKey = String(this.getUIState('capSelectedKey') || '');
let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
if (selectedIndex === -1) {
selectedIndex = Number(this.getUIState('capSelectedIndex'));
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
selectedIndex = 0;
}
}
// Compute padding for aligned display in dropdown
const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
const selectorOptions = endpoints.map((ep, idx) => {
const method = String(ep.method || '');
const uri = String(ep.uri || '');
const padCount = Math.max(1, (maxMethodLen - method.length) + 2);
const spacer = '&nbsp;'.repeat(padCount);
return `<option value="${idx}" data-method="${method}" data-uri="${uri}" ${idx === selectedIndex ? 'selected' : ''}>${method}${spacer}${uri}</option>`;
}).join('');
const items = endpoints.map((ep, idx) => {
const formId = `cap-form-${idx}`;
const resultId = `cap-result-${idx}`;
const params = Array.isArray(ep.params) && ep.params.length > 0
? `<div class="capability-params">${ep.params.map((p, pidx) => `
<label class="capability-param" for="${formId}-field-${pidx}">
<span class="param-name">${p.name}${p.required ? ' *' : ''}</span>
${ (Array.isArray(p.values) && p.values.length > 1)
? `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`
: `<input id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input" type="text" placeholder="${p.location || 'body'}${p.type || 'string'}" value="${(Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : ''}">`
}
</label>
`).join('')}</div>`
: '<div class="capability-params none">No parameters</div>';
return `
<div class="capability-item" data-cap-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
<div class="capability-header">
<span class="cap-method">${ep.method}</span>
<span class="cap-uri">${ep.uri}</span>
<button class="cap-call-btn" data-action="call-capability" data-method="${ep.method}" data-uri="${ep.uri}" data-form-id="${formId}" data-result-id="${resultId}">Call</button>
</div>
<form id="${formId}" class="capability-form" onsubmit="return false;">
${params}
</form>
<div id="${resultId}" class="capability-result" style="display:none;"></div>
</div>
`;
}).join('');
// Attach events after render in setupCapabilitiesEvents()
setTimeout(() => this.setupCapabilitiesEvents(), 0);
return `
<div class="capability-selector">
<label class="param-name" for="capability-select">Capability</label>
<select id="capability-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
</div>
<div class="capabilities-list">${items}</div>
`;
}
setupCapabilitiesEvents() {
const selector = this.findElement('#capability-select');
if (selector) {
this.addEventListener(selector, 'change', (e) => {
const selected = Number(e.target.value);
const items = Array.from(this.findAllElements('.capability-item'));
items.forEach((el, idx) => {
el.style.display = (idx === selected) ? '' : 'none';
});
this.setUIState('capSelectedIndex', selected);
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
if (opt) {
const method = opt.dataset.method || '';
const uri = opt.dataset.uri || '';
this.setUIState('capSelectedKey', `${method} ${uri}`);
}
});
}
const buttons = this.findAllElements('.cap-call-btn');
buttons.forEach(btn => {
this.addEventListener(btn, 'click', async (e) => {
e.stopPropagation();
const method = btn.dataset.method || 'GET';
const uri = btn.dataset.uri || '';
const formId = btn.dataset.formId;
const resultId = btn.dataset.resultId;
const formEl = this.findElement(`#${formId}`);
const resultEl = this.findElement(`#${resultId}`);
if (!formEl || !resultEl) return;
const inputs = Array.from(formEl.querySelectorAll('.param-input'));
const params = inputs.map(input => ({
name: input.dataset.paramName,
location: input.dataset.paramLocation || 'body',
type: input.dataset.paramType || 'string',
required: input.dataset.paramRequired === '1',
value: input.value
}));
// Required validation
const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === ''));
if (missing.length > 0) {
resultEl.style.display = 'block';
resultEl.innerHTML = `
<div class="cap-call-error">
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
</div>
`;
return;
}
// Show loading state
resultEl.style.display = 'block';
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
try {
const response = await this.viewModel.callCapability(method, uri, params);
const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? '');
resultEl.innerHTML = `
<div class="cap-call-success">
<div>✅ Success</div>
<pre class="cap-result-pre">${this.escapeHtml(pretty)}</pre>
</div>
`;
} catch (err) {
resultEl.innerHTML = `
<div class="cap-call-error">
<div>❌ Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
</div>
`;
}
});
});
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
renderTasksTab(tasks) {
const summary = this.viewModel.get('tasksSummary');
if (tasks && tasks.length > 0) {
const summaryHTML = summary ? `
<div class="tasks-summary">
<div class="tasks-summary-left">
<div class="summary-icon">📋</div>
<div>
<div class="summary-title">Tasks Overview</div>
<div class="summary-subtitle">System task management and monitoring</div>
</div>
</div>
<div class="tasks-summary-right">
<div class="summary-stat total">
<div class="summary-stat-value">${summary.totalTasks ?? tasks.length}</div>
<div class="summary-stat-label">Total</div>
</div>
<div class="summary-stat active">
<div class="summary-stat-value">${summary.activeTasks ?? tasks.filter(t => t.running).length}</div>
<div class="summary-stat-label">Active</div>
</div>
<div class="summary-stat stopped">
<div class="summary-stat-value">${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}</div>
<div class="summary-stat-label">Stopped</div>
</div>
</div>
</div>
` : '';
const tasksHTML = tasks.map(task => `
<div class="task-item">
<div class="task-header">
<span class="task-name">${task.name || 'Unknown Task'}</span>
<span class="task-status ${task.running ? 'running' : 'stopped'}">
${task.running ? '🟢 Running' : '🔴 Stopped'}
</span>
</div>
<div class="task-details">
<span class="task-interval">Interval: ${task.interval}ms</span>
<span class="task-enabled">${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}</span>
</div>
</div>
`).join('');
return `
${summaryHTML}
${tasksHTML}
`;
} else {
const total = summary?.totalTasks ?? 0;
const active = summary?.activeTasks ?? 0;
return `
<div class="tasks-summary">
<div class="tasks-summary-left">
<div class="summary-icon">📋</div>
<div>
<div class="summary-title">Tasks Overview</div>
<div class="summary-subtitle">${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}</div>
</div>
</div>
<div class="tasks-summary-right">
<div class="summary-stat total">
<div class="summary-stat-value">${total}</div>
<div class="summary-stat-label">Total</div>
</div>
<div class="summary-stat active">
<div class="summary-stat-value">${active}</div>
<div class="summary-stat-label">Active</div>
</div>
<div class="summary-stat stopped">
<div class="summary-stat-value">${total - active}</div>
<div class="summary-stat-label">Stopped</div>
</div>
</div>
</div>
<div class="no-tasks">
<div>📋 No active tasks found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
</div>
</div>
`;
}
}
renderFirmwareTab() {
return `
<div class="firmware-upload">
<h4>Firmware Update</h4>
<div class="upload-area">
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
<button class="upload-btn" data-action="select-file">
📁 Choose Firmware File
</button>
<div class="upload-info">Select a .bin or .hex file to upload</div>
<div id="upload-status" style="display: none;"></div>
</div>
</div>
`;
}
setupTabs() {
logger.debug('NodeDetailsComponent: Setting up tabs');
super.setupTabs(this.container, {
onChange: (tab) => {
// Persist active tab in the view model for restoration
if (this.viewModel && typeof this.viewModel.setActiveTab === 'function') {
this.viewModel.setActiveTab(tab);
}
}
});
}
// Update active tab without full re-render
updateActiveTab(newTab, previousTab = null) {
this.setActiveTab(newTab);
logger.debug(`NodeDetailsComponent: Active tab updated to '${newTab}'`);
}
setupFirmwareUpload() {
const uploadBtn = this.findElement('.upload-btn[data-action="select-file"]');
if (uploadBtn) {
this.addEventListener(uploadBtn, 'click', (e) => {
e.stopPropagation();
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
fileInput.click();
}
});
// Set up file input change handler
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
this.addEventListener(fileInput, 'change', async (e) => {
e.stopPropagation();
const file = e.target.files[0];
if (file) {
await this.uploadFirmware(file);
}
});
}
}
}
async uploadFirmware(file) {
const uploadStatus = this.findElement('#upload-status');
const uploadBtn = this.findElement('.upload-btn');
const originalText = uploadBtn.textContent;
try {
// Show upload status
uploadStatus.style.display = 'block';
uploadStatus.innerHTML = `
<div class="upload-progress">
<div>📤 Uploading ${file.name}...</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
// Disable upload button
uploadBtn.disabled = true;
uploadBtn.textContent = '⏳ Uploading...';
// Get the member IP from the card
const memberCard = this.container.closest('.member-card');
const memberIp = memberCard.dataset.memberIp;
if (!memberIp) {
throw new Error('Could not determine target node IP address');
}
// Upload firmware
const result = await this.viewModel.uploadFirmware(file, memberIp);
// Show success
uploadStatus.innerHTML = `
<div class="upload-success">
<div>✅ Firmware uploaded successfully!</div>
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Node: ${memberIp}</div>
<div style="font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
logger.debug('Firmware upload successful:', result);
} catch (error) {
logger.error('Firmware upload failed:', error);
// Show error
uploadStatus.innerHTML = `
<div class="upload-error">
<div>❌ Upload failed: ${error.message}</div>
</div>
`;
} finally {
// Re-enable upload button
uploadBtn.disabled = false;
uploadBtn.textContent = originalText;
// Clear file input
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
fileInput.value = '';
}
}
}
}
window.NodeDetailsComponent = NodeDetailsComponent;

View File

@@ -0,0 +1,90 @@
// Primary Node Component
class PrimaryNodeComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupEventListeners() {
const refreshBtn = this.findElement('.primary-node-refresh');
if (refreshBtn) {
this.addEventListener(refreshBtn, 'click', this.handleRandomSelection.bind(this));
}
}
setupViewModelListeners() {
// Listen to primary node changes
this.subscribeToProperty('primaryNode', this.render.bind(this));
this.subscribeToProperty('clientInitialized', this.render.bind(this));
this.subscribeToProperty('totalNodes', this.render.bind(this));
this.subscribeToProperty('onlineNodes', this.render.bind(this));
this.subscribeToProperty('error', this.render.bind(this));
}
render() {
const primaryNode = this.viewModel.get('primaryNode');
const clientInitialized = this.viewModel.get('clientInitialized');
const totalNodes = this.viewModel.get('totalNodes');
const onlineNodes = this.viewModel.get('onlineNodes');
const error = this.viewModel.get('error');
if (error) {
this.setText('#primary-node-ip', '❌ Discovery Failed');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
return;
}
if (!primaryNode) {
this.setText('#primary-node-ip', '🔍 No Nodes Found');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
return;
}
const status = clientInitialized ? '✅' : '⚠️';
const nodeCount = (onlineNodes && onlineNodes > 0)
? ` (${onlineNodes}/${totalNodes} online)`
: (totalNodes > 1 ? ` (${totalNodes} nodes)` : '');
this.setText('#primary-node-ip', `${status} ${primaryNode}${nodeCount}`);
this.setClass('#primary-node-ip', 'error', false);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
}
async handleRandomSelection() {
try {
// Show selecting state
this.setText('#primary-node-ip', '🎲 Selecting...');
this.setClass('#primary-node-ip', 'selecting', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'error', false);
await this.viewModel.selectRandomPrimaryNode();
// Show success briefly
this.setText('#primary-node-ip', '🎯 Selection Complete');
// Update display after delay
setTimeout(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 1500);
} catch (error) {
logger.error('Failed to select random primary node:', error);
this.setText('#primary-node-ip', '❌ Selection Failed');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'selecting', false);
this.setClass('#primary-node-ip', 'discovering', false);
// Revert to normal display after error
setTimeout(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 2000);
}
}
}
window.PrimaryNodeComponent = PrimaryNodeComponent;

View File

@@ -0,0 +1,802 @@
// Topology Graph Component with D3.js force-directed visualization
class TopologyGraphComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('TopologyGraphComponent: Constructor called');
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 0; // Will be set dynamically based on container size
this.height = 0; // Will be set dynamically based on container size
this.isInitialized = false;
}
updateDimensions(container) {
// Get the container's actual dimensions
const rect = container.getBoundingClientRect();
this.width = rect.width || 1400; // Fallback to 1400 if width is 0
this.height = rect.height || 1000; // Fallback to 1000 if height is 0
// Ensure minimum dimensions
this.width = Math.max(this.width, 800);
this.height = Math.max(this.height, 600);
logger.debug('TopologyGraphComponent: Updated dimensions to', this.width, 'x', this.height);
}
handleResize() {
// Debounce resize events to avoid excessive updates
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = setTimeout(() => {
const container = this.findElement('#topology-graph-container');
if (container && this.svg) {
this.updateDimensions(container);
// Update SVG viewBox and force center
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);
if (this.simulation) {
this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2));
this.simulation.alpha(0.3).restart();
}
}
}, 250); // 250ms debounce
}
// Override mount to ensure proper initialization
mount() {
if (this.isMounted) return;
logger.debug('TopologyGraphComponent: Starting mount...');
logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized);
// Call initialize if not already done
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Initializing during mount...');
this.initialize().then(() => {
logger.debug('TopologyGraphComponent: Initialization completed, calling completeMount...');
// Complete mount after initialization
this.completeMount();
}).catch(error => {
logger.error('TopologyGraphComponent: Initialization failed during mount:', error);
// Still complete mount to prevent blocking
this.completeMount();
});
} else {
logger.debug('TopologyGraphComponent: Already initialized, calling completeMount directly...');
this.completeMount();
}
}
completeMount() {
logger.debug('TopologyGraphComponent: completeMount called');
this.isMounted = true;
logger.debug('TopologyGraphComponent: Setting up event listeners...');
this.setupEventListeners();
logger.debug('TopologyGraphComponent: Setting up view model listeners...');
this.setupViewModelListeners();
logger.debug('TopologyGraphComponent: Calling render...');
this.render();
logger.debug('TopologyGraphComponent: Mounted successfully');
}
setupEventListeners() {
logger.debug('TopologyGraphComponent: setupEventListeners called');
logger.debug('TopologyGraphComponent: Container:', this.container);
logger.debug('TopologyGraphComponent: Container ID:', this.container?.id);
// Add resize listener to update dimensions when window is resized
this.resizeHandler = this.handleResize.bind(this);
window.addEventListener('resize', this.resizeHandler);
// Refresh button removed from HTML, so no need to set up event listeners
logger.debug('TopologyGraphComponent: No event listeners needed (refresh button removed)');
}
setupViewModelListeners() {
logger.debug('TopologyGraphComponent: setupViewModelListeners called');
logger.debug('TopologyGraphComponent: isInitialized =', this.isInitialized);
if (this.isInitialized) {
// Component is already initialized, set up subscriptions immediately
logger.debug('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
logger.debug('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() {
logger.debug('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) {
logger.error('TopologyGraphComponent: Graph container not found');
return;
}
// Calculate dynamic dimensions based on container size
this.updateDimensions(container);
// Clear existing content
container.innerHTML = '';
// Create SVG element
this.svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.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])
.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)');
logger.debug('TopologyGraphComponent: SVG setup completed');
}
// Ensure component is initialized
async ensureInitialized() {
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Ensuring initialization...');
await this.initialize();
}
return this.isInitialized;
}
renderGraph() {
try {
// Check if component is initialized
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
this.ensureInitialized().then(() => {
// Re-render after initialization
this.renderGraph();
}).catch(error => {
logger.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) {
logger.debug('TopologyGraphComponent: SVG not initialized yet, setting up SVG first');
this.setupSVG();
}
if (!nodes || nodes.length === 0) {
this.showNoData();
return;
}
logger.debug('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()) {
logger.debug('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)');
}
// Clear existing graph elements but preserve the main group and its transform
svgGroup.selectAll('.graph-element').remove();
// Create links
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)
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)))
.attr('marker-end', null);
// 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
node.append('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('fill', d => this.getNodeColor(d.status))
.attr('stroke', '#fff')
.attr('stroke-width', 2);
// Status indicator
node.append('circle')
.attr('r', 3)
.attr('fill', d => this.getStatusIndicatorColor(d.status))
.attr('cx', -8)
.attr('cy', -8);
// Hostname
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')
.attr('fill', '#ecf0f1')
.attr('font-weight', '500');
// IP
node.append('text')
.text(d => d.ip)
.attr('x', 15)
.attr('y', 20)
.attr('font-size', '11px')
.attr('fill', 'rgba(255, 255, 255, 0.7)');
// Status text
node.append('text')
.text(d => d.status)
.attr('x', 15)
.attr('y', 35)
.attr('font-size', '11px')
.attr('fill', d => this.getNodeColor(d.status))
.attr('font-weight', '600');
// Latency labels on links
const linkLabels = svgGroup.append('g')
.attr('class', 'graph-element')
.selectAll('text')
.data(links)
.enter().append('text')
.attr('font-size', '12px')
.attr('fill', '#ecf0f1')
.attr('font-weight', '600')
.attr('text-anchor', 'middle')
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)')
.text(d => `${d.latency}ms`);
// Simulation
if (!this.simulation) {
this.simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(300))
.force('charge', d3.forceManyBody().strength(-800))
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
.force('collision', d3.forceCollide().radius(80));
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);
linkLabels
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
node
.attr('transform', d => `translate(${d.x},${d.y})`);
});
} else {
this.simulation.nodes(nodes);
this.simulation.force('link').links(links);
this.simulation.alpha(0.3).restart();
}
// Node interactions
node.on('click', (event, d) => {
this.viewModel.selectNode(d.id);
this.updateSelection(d.id);
this.showMemberCardOverlay(d);
});
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);
});
link.on('mouseover', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6)))
.attr('stroke-opacity', 0.9);
});
link.on('mouseout', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)))
.attr('stroke-opacity', 0.7);
});
this.addLegend(svgGroup);
} catch (error) {
logger.error('Failed to render graph:', error);
}
}
addLegend(svgGroup) {
const legend = svgGroup.append('g')
.attr('class', 'graph-element')
.attr('transform', `translate(120, 120)`) // Hidden by CSS opacity
.style('opacity', '0');
legend.append('rect')
.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)')
.attr('stroke-width', 1);
const nodeLegend = legend.append('g')
.attr('transform', 'translate(20, 20)');
nodeLegend.append('text')
.text('Node Status:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px')
.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')
.attr('fill', '#ecf0f1');
});
const linkLegend = legend.append('g')
.attr('transform', 'translate(150, 20)');
linkLegend.append('text')
.text('Link Latency:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px')
.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);
linkLegend.append('text')
.text(item.range)
.attr('x', 25)
.attr('y', item.y + 4)
.attr('font-size', '12px')
.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';
case 'INACTIVE':
return '#f59e0b';
case 'DEAD':
return '#ef4444';
default:
return '#6b7280';
}
}
getLinkColor(latency) {
if (latency <= 30) return '#10b981';
if (latency <= 50) return '#f59e0b';
return '#ef4444';
}
getNodeColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#10b981';
case 'INACTIVE':
return '#f59e0b';
case 'DEAD':
return '#ef4444';
default:
return '#6b7280';
}
}
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() {
logger.debug('TopologyGraphComponent: handleRefresh called');
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
this.ensureInitialized().then(() => {
// Refresh after initialization
this.viewModel.updateNetworkTopology();
}).catch(error => {
logger.error('TopologyGraphComponent: Failed to initialize for refresh:', error);
});
return;
}
logger.debug('TopologyGraphComponent: Calling updateNetworkTopology...');
this.viewModel.updateNetworkTopology();
}
handleLoadingState(isLoading) {
logger.debug('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>';
}
showMemberCardOverlay(nodeData) {
// Create overlay container if it doesn't exist
let overlayContainer = document.getElementById('member-card-overlay');
if (!overlayContainer) {
overlayContainer = document.createElement('div');
overlayContainer.id = 'member-card-overlay';
overlayContainer.className = 'member-card-overlay';
document.body.appendChild(overlayContainer);
}
// Create and show the overlay component
if (!this.memberOverlayComponent) {
const overlayVM = new ViewModel();
this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus);
this.memberOverlayComponent.mount();
}
// Convert node data to member data format
const memberData = {
ip: nodeData.ip,
hostname: nodeData.hostname,
status: this.normalizeStatus(nodeData.status),
latency: nodeData.latency,
labels: nodeData.resources || {}
};
this.memberOverlayComponent.show(memberData);
}
// Normalize status from topology format to member card format
normalizeStatus(status) {
if (!status) return 'unknown';
const normalized = status.toLowerCase();
switch (normalized) {
case 'active':
return 'active';
case 'inactive':
return 'inactive';
case 'dead':
return 'offline';
default:
return 'unknown';
}
}
// Override render method to display the graph
render() {
logger.debug('TopologyGraphComponent: render called');
if (!this.isInitialized) {
logger.debug('TopologyGraphComponent: Not initialized yet, skipping render');
return;
}
const nodes = this.viewModel.get('nodes');
const links = this.viewModel.get('links');
if (nodes && nodes.length > 0) {
logger.debug('TopologyGraphComponent: Rendering graph with data');
this.renderGraph();
} else {
logger.debug('TopologyGraphComponent: No data available, showing loading state');
this.handleLoadingState(true);
}
}
unmount() {
// Clean up resize listener
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
this.resizeHandler = null;
}
// Clear resize timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}
// Call parent unmount
super.unmount();
}
}
// Minimal Member Card Overlay Component (kept in same file to avoid circular loads)
class MemberCardOverlayComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.isVisible = false;
this.currentMember = null;
}
mount() {
super.mount();
this.setupEventListeners();
}
setupEventListeners() {
// Close overlay when clicking outside or pressing escape
this.addEventListener(this.container, 'click', (e) => {
if (!this.isVisible) return;
if (e.target === this.container) {
this.hide();
}
});
this.addEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape' && this.isVisible) {
this.hide();
}
});
}
show(memberData) {
this.currentMember = memberData;
this.isVisible = true;
const memberCardHTML = this.renderMemberCard(memberData);
this.setHTML('', memberCardHTML);
setTimeout(() => {
this.container.classList.add('visible');
}, 10);
this.setupMemberCardInteractions();
}
hide() {
this.isVisible = false;
this.container.classList.remove('visible');
this.currentMember = null;
}
renderMemberCard(member) {
const statusClass = member.status === 'active' ? 'status-online' :
member.status === 'inactive' ? 'status-inactive' : 'status-offline';
const statusIcon = member.status === 'active' ? '🟢' :
member.status === 'inactive' ? '🟠' : '🔴';
return `
<div class="member-overlay-content">
<div class="member-overlay-header">
<div class="member-info">
<div class="member-row-1">
<div class="status-hostname-group">
<div class="member-status ${statusClass}">
${statusIcon}
</div>
<div class="member-hostname">${member.hostname || 'Unknown Device'}</div>
</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
</div>
<div class="member-labels" style="display: none;"></div>
</div>
<button class="member-overlay-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div class="member-overlay-body">
<div class="member-card expanded" data-member-ip="${member.ip}">
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
</div>
</div>
`;
}
setupMemberCardInteractions() {
const closeBtn = this.findElement('.member-overlay-close');
if (closeBtn) {
this.addEventListener(closeBtn, 'click', () => {
this.hide();
});
}
setTimeout(async () => {
const memberCard = this.findElement('.member-card');
if (memberCard) {
const memberDetails = memberCard.querySelector('.member-details');
const memberIp = memberCard.dataset.memberIp;
await this.expandCard(memberCard, memberIp, memberDetails);
}
}, 100);
}
async expandCard(card, memberIp, memberDetails) {
try {
const nodeDetailsVM = new NodeDetailsViewModel();
const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus);
await nodeDetailsVM.loadNodeDetails(memberIp);
const nodeStatus = nodeDetailsVM.get('nodeStatus');
if (nodeStatus && nodeStatus.labels) {
const labelsContainer = document.querySelector('.member-overlay-header .member-labels');
if (labelsContainer) {
labelsContainer.innerHTML = Object.entries(nodeStatus.labels)
.map(([key, value]) => `<span class="label-chip">${key}: ${value}</span>`)
.join('');
labelsContainer.style.display = 'block';
}
}
nodeDetailsComponent.mount();
card.classList.add('expanded');
} catch (error) {
logger.error('Failed to expand member card:', error);
card.classList.add('expanded');
const details = card.querySelector('.member-details');
if (details) {
details.innerHTML = '<div class="error">Failed to load node details</div>';
}
}
}
}
window.TopologyGraphComponent = TopologyGraphComponent;
window.MemberCardOverlayComponent = MemberCardOverlayComponent;

View File

@@ -0,0 +1,27 @@
(function(){
const TIMING = {
NAV_COOLDOWN_MS: 300,
VIEW_FADE_OUT_MS: 150,
VIEW_FADE_IN_MS: 200,
VIEW_FADE_DELAY_MS: 50,
AUTO_REFRESH_MS: 30000,
PRIMARY_NODE_REFRESH_MS: 10000,
LOAD_GUARD_MS: 10000
};
const SELECTORS = {
NAV_TAB: '.nav-tab',
VIEW_CONTENT: '.view-content',
CLUSTER_STATUS: '.cluster-status'
};
const CLASSES = {
CLUSTER_STATUS_ONLINE: 'cluster-status-online',
CLUSTER_STATUS_OFFLINE: 'cluster-status-offline',
CLUSTER_STATUS_CONNECTING: 'cluster-status-connecting',
CLUSTER_STATUS_ERROR: 'cluster-status-error',
CLUSTER_STATUS_DISCOVERING: 'cluster-status-discovering'
};
window.CONSTANTS = window.CONSTANTS || { TIMING, SELECTORS, CLASSES };
})();

View File

@@ -88,7 +88,7 @@ class ViewModel {
// Set data property and notify listeners // Set data property and notify listeners
set(property, value) { set(property, value) {
console.log(`ViewModel: Setting property '${property}' to:`, value); logger.debug(`ViewModel: Setting property '${property}' to:`, value);
// Check if the value has actually changed // Check if the value has actually changed
const hasChanged = this._data[property] !== value; const hasChanged = this._data[property] !== value;
@@ -100,10 +100,10 @@ class ViewModel {
// Update the data // Update the data
this._data[property] = value; this._data[property] = value;
console.log(`ViewModel: Property '${property}' changed, notifying listeners...`); logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`);
this._notifyListeners(property, value, this._previousData[property]); this._notifyListeners(property, value, this._previousData[property]);
} else { } else {
console.log(`ViewModel: Property '${property}' unchanged, skipping notification`); logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`);
} }
} }
@@ -132,7 +132,7 @@ class ViewModel {
}); });
if (Object.keys(changedProperties).length > 0) { if (Object.keys(changedProperties).length > 0) {
console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties)); logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
} }
} }
@@ -157,20 +157,20 @@ class ViewModel {
// Notify listeners of property changes // Notify listeners of property changes
_notifyListeners(property, value, previousValue) { _notifyListeners(property, value, previousValue) {
console.log(`ViewModel: _notifyListeners called for property '${property}'`); logger.debug(`ViewModel: _notifyListeners called for property '${property}'`);
if (this._listeners.has(property)) { if (this._listeners.has(property)) {
const callbacks = this._listeners.get(property); const callbacks = this._listeners.get(property);
console.log(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`); logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
callbacks.forEach((callback, index) => { callbacks.forEach((callback, index) => {
try { try {
console.log(`ViewModel: Calling listener ${index} for property '${property}'`); logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`);
callback(value, previousValue); callback(value, previousValue);
} catch (error) { } catch (error) {
console.error(`Error in property listener for ${property}:`, error); console.error(`Error in property listener for ${property}:`, error);
} }
}); });
} else { } else {
console.log(`ViewModel: No listeners found for property '${property}'`); logger.debug(`ViewModel: No listeners found for property '${property}'`);
} }
} }
@@ -285,13 +285,13 @@ class Component {
mount() { mount() {
if (this.isMounted) return; if (this.isMounted) return;
console.log(`${this.constructor.name}: Starting mount...`); logger.debug(`${this.constructor.name}: Starting mount...`);
this.isMounted = true; this.isMounted = true;
this.setupEventListeners(); this.setupEventListeners();
this.setupViewModelListeners(); this.setupViewModelListeners();
this.render(); this.render();
console.log(`${this.constructor.name}: Mounted successfully`); logger.debug(`${this.constructor.name}: Mounted successfully`);
} }
// Unmount the component // Unmount the component
@@ -302,14 +302,14 @@ class Component {
this.cleanupEventListeners(); this.cleanupEventListeners();
this.cleanupViewModelListeners(); this.cleanupViewModelListeners();
console.log(`${this.constructor.name} unmounted`); logger.debug(`${this.constructor.name} unmounted`);
} }
// Pause the component (keep alive but pause activity) // Pause the component (keep alive but pause activity)
pause() { pause() {
if (!this.isMounted) return; if (!this.isMounted) return;
console.log(`${this.constructor.name}: Pausing component`); logger.debug(`${this.constructor.name}: Pausing component`);
// Pause any active timers or animations // Pause any active timers or animations
if (this.updateInterval) { if (this.updateInterval) {
@@ -328,7 +328,7 @@ class Component {
resume() { resume() {
if (!this.isMounted || !this.isPaused) return; if (!this.isMounted || !this.isPaused) return;
console.log(`${this.constructor.name}: Resuming component`); logger.debug(`${this.constructor.name}: Resuming component`);
this.isPaused = false; this.isPaused = false;
@@ -385,7 +385,7 @@ class Component {
// Partial update method for efficient data updates // Partial update method for efficient data updates
updatePartial(property, newValue, previousValue) { updatePartial(property, newValue, previousValue) {
// Override in subclasses to implement partial updates // Override in subclasses to implement partial updates
console.log(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue }); logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
} }
// UI State Management Methods // UI State Management Methods
@@ -474,22 +474,22 @@ class Component {
// Helper method to set innerHTML safely // Helper method to set innerHTML safely
setHTML(selector, html) { setHTML(selector, html) {
console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`); logger.debug(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
let element; let element;
if (selector === '') { if (selector === '') {
// Empty selector means set HTML on the component's container itself // Empty selector means set HTML on the component's container itself
element = this.container; element = this.container;
console.log(`${this.constructor.name}: Using component container for empty selector`); logger.debug(`${this.constructor.name}: Using component container for empty selector`);
} else { } else {
// Find element within the component's container // Find element within the component's container
element = this.findElement(selector); element = this.findElement(selector);
} }
if (element) { if (element) {
console.log(`${this.constructor.name}: Element found, setting innerHTML`); logger.debug(`${this.constructor.name}: Element found, setting innerHTML`);
element.innerHTML = html; element.innerHTML = html;
console.log(`${this.constructor.name}: innerHTML set successfully`); logger.debug(`${this.constructor.name}: innerHTML set successfully`);
} else { } else {
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`); console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
} }
@@ -550,7 +550,7 @@ class Component {
} }
renderError(message) { renderError(message) {
const safe = String(message || 'An error occurred'); const safe = this.escapeHtml(String(message || 'An error occurred'));
const html = ` const html = `
<div class="error"> <div class="error">
<strong>Error:</strong><br> <strong>Error:</strong><br>
@@ -569,8 +569,19 @@ class Component {
this.setHTML('', html); this.setHTML('', html);
} }
// Basic HTML escaping for dynamic values
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Tab helpers // Tab helpers
setupTabs(container = this.container) { setupTabs(container = this.container, options = {}) {
const { onChange } = options;
const tabButtons = container.querySelectorAll('.tab-button'); const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content'); const tabContents = container.querySelectorAll('.tab-content');
tabButtons.forEach(button => { tabButtons.forEach(button => {
@@ -578,6 +589,9 @@ class Component {
e.stopPropagation(); e.stopPropagation();
const targetTab = button.dataset.tab; const targetTab = button.dataset.tab;
this.setActiveTab(targetTab, container); this.setActiveTab(targetTab, container);
if (typeof onChange === 'function') {
try { onChange(targetTab); } catch (_) {}
}
}); });
}); });
tabContents.forEach(content => { tabContents.forEach(content => {
@@ -610,7 +624,7 @@ class App {
this.navigationInProgress = false; this.navigationInProgress = false;
this.navigationQueue = []; this.navigationQueue = [];
this.lastNavigationTime = 0; this.lastNavigationTime = 0;
this.navigationCooldown = 300; // 300ms cooldown between navigations this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300; // cooldown between navigations
// Component cache to keep components alive // Component cache to keep components alive
this.componentCache = new Map(); this.componentCache = new Map();
@@ -621,8 +635,8 @@ class App {
registerRoute(name, componentClass, containerId, viewModel = null) { registerRoute(name, componentClass, containerId, viewModel = null) {
this.routes.set(name, { componentClass, containerId, viewModel }); this.routes.set(name, { componentClass, containerId, viewModel });
// Pre-initialize component in cache for better performance // Defer instantiation until navigation to reduce startup work
this.preInitializeComponent(name, componentClass, containerId, viewModel); // this.preInitializeComponent(name, componentClass, containerId, viewModel);
} }
// Pre-initialize component in cache // Pre-initialize component in cache
@@ -637,7 +651,7 @@ class App {
// Store in cache // Store in cache
this.componentCache.set(name, component); this.componentCache.set(name, component);
console.log(`App: Pre-initialized component for route '${name}'`); logger.debug(`App: Pre-initialized component for route '${name}'`);
} }
// Navigate to a route // Navigate to a route
@@ -645,13 +659,13 @@ class App {
// Check cooldown period // Check cooldown period
const now = Date.now(); const now = Date.now();
if (now - this.lastNavigationTime < this.navigationCooldown) { if (now - this.lastNavigationTime < this.navigationCooldown) {
console.log(`App: Navigation cooldown active, skipping route '${routeName}'`); logger.debug(`App: Navigation cooldown active, skipping route '${routeName}'`);
return; return;
} }
// If navigation is already in progress, queue this request // If navigation is already in progress, queue this request
if (this.navigationInProgress) { if (this.navigationInProgress) {
console.log(`App: Navigation in progress, queuing route '${routeName}'`); logger.debug(`App: Navigation in progress, queuing route '${routeName}'`);
if (!this.navigationQueue.includes(routeName)) { if (!this.navigationQueue.includes(routeName)) {
this.navigationQueue.push(routeName); this.navigationQueue.push(routeName);
} }
@@ -660,7 +674,7 @@ class App {
// If trying to navigate to the same route, do nothing // If trying to navigate to the same route, do nothing
if (this.currentView && this.currentView.routeName === routeName) { if (this.currentView && this.currentView.routeName === routeName) {
console.log(`App: Already on route '${routeName}', skipping navigation`); logger.debug(`App: Already on route '${routeName}', skipping navigation`);
return; return;
} }
@@ -673,19 +687,19 @@ class App {
this.navigationInProgress = true; this.navigationInProgress = true;
try { try {
console.log(`App: Navigating to route '${routeName}'`); logger.debug(`App: Navigating to route '${routeName}'`);
const route = this.routes.get(routeName); const route = this.routes.get(routeName);
if (!route) { if (!route) {
console.error(`Route '${routeName}' not found`); console.error(`Route '${routeName}' not found`);
return; return;
} }
console.log(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`); logger.debug(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
// Get or create component from cache // Get or create component from cache
let component = this.componentCache.get(routeName); let component = this.componentCache.get(routeName);
if (!component) { if (!component) {
console.log(`App: Component not in cache, creating new instance for '${routeName}'`); logger.debug(`App: Component not in cache, creating new instance for '${routeName}'`);
const container = document.getElementById(route.containerId); const container = document.getElementById(route.containerId);
if (!container) { if (!container) {
console.error(`Container '${route.containerId}' not found`); console.error(`Container '${route.containerId}' not found`);
@@ -700,12 +714,12 @@ class App {
// Hide current view smoothly // Hide current view smoothly
if (this.currentView) { if (this.currentView) {
console.log('App: Hiding current view'); logger.debug('App: Hiding current view');
await this.hideCurrentView(); await this.hideCurrentView();
} }
// Show new view // Show new view
console.log(`App: Showing new view '${routeName}'`); logger.debug(`App: Showing new view '${routeName}'`);
await this.showView(routeName, component); await this.showView(routeName, component);
// Update navigation state // Update navigation state
@@ -717,7 +731,7 @@ class App {
// Mark view as cached for future use // Mark view as cached for future use
this.cachedViews.add(routeName); this.cachedViews.add(routeName);
console.log(`App: Navigation to '${routeName}' completed`); logger.debug(`App: Navigation to '${routeName}' completed`);
} catch (error) { } catch (error) {
console.error('App: Navigation failed:', error); console.error('App: Navigation failed:', error);
@@ -727,7 +741,7 @@ class App {
// Process any queued navigation requests // Process any queued navigation requests
if (this.navigationQueue.length > 0) { if (this.navigationQueue.length > 0) {
const nextRoute = this.navigationQueue.shift(); const nextRoute = this.navigationQueue.shift();
console.log(`App: Processing queued navigation to '${nextRoute}'`); logger.debug(`App: Processing queued navigation to '${nextRoute}'`);
setTimeout(() => this.navigateTo(nextRoute), 100); setTimeout(() => this.navigateTo(nextRoute), 100);
} }
} }
@@ -739,39 +753,47 @@ class App {
// If component is mounted, pause it instead of unmounting // If component is mounted, pause it instead of unmounting
if (this.currentView.isMounted) { if (this.currentView.isMounted) {
console.log('App: Pausing current view instead of unmounting'); logger.debug('App: Pausing current view instead of unmounting');
this.currentView.pause(); this.currentView.pause();
} }
// Fade out the container // Fade out the container
if (this.currentView.container) { if (this.currentView.container) {
this.currentView.container.style.opacity = '0'; this.currentView.container.style.opacity = '0';
this.currentView.container.style.transition = 'opacity 0.15s ease-out'; this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`;
} }
// Wait for fade out to complete // Wait for fade out to complete
await new Promise(resolve => setTimeout(resolve, 150)); await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150));
} }
// Show view smoothly // Show view smoothly
async showView(routeName, component) { async showView(routeName, component) {
const container = component.container; const container = component.container;
// Ensure component is mounted (but not necessarily active) // Ensure component is mounted (but not necessarily active); lazy-create now if needed
if (!component) {
const route = this.routes.get(routeName);
const container = document.getElementById(route.containerId);
component = new route.componentClass(container, route.viewModel, this.eventBus);
component.routeName = routeName;
component.isCached = true;
this.componentCache.set(routeName, component);
}
if (!component.isMounted) { if (!component.isMounted) {
console.log(`App: Mounting component for '${routeName}'`); logger.debug(`App: Mounting component for '${routeName}'`);
component.mount(); component.mount();
} else { } else {
console.log(`App: Resuming component for '${routeName}'`); logger.debug(`App: Resuming component for '${routeName}'`);
component.resume(); component.resume();
} }
// Fade in the container // Fade in the container
container.style.opacity = '0'; container.style.opacity = '0';
container.style.transition = 'opacity 0.2s ease-in'; container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`;
// Small delay to ensure smooth transition // Small delay to ensure smooth transition
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50));
// Fade in // Fade in
container.style.opacity = '1'; container.style.opacity = '1';
@@ -780,7 +802,7 @@ class App {
// Update navigation state // Update navigation state
updateNavigation(activeRoute) { updateNavigation(activeRoute) {
// Remove active class from all nav tabs // Remove active class from all nav tabs
document.querySelectorAll('.nav-tab').forEach(tab => { document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
tab.classList.remove('active'); tab.classList.remove('active');
}); });
@@ -791,7 +813,7 @@ class App {
} }
// Hide all view contents with smooth transition // Hide all view contents with smooth transition
document.querySelectorAll('.view-content').forEach(view => { document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => {
view.classList.remove('active'); view.classList.remove('active');
view.style.opacity = '0'; view.style.opacity = '0';
view.style.transition = 'opacity 0.15s ease-out'; view.style.transition = 'opacity 0.15s ease-out';
@@ -826,7 +848,7 @@ class App {
// Initialize the application // Initialize the application
init() { init() {
console.log('SPORE UI Framework initialized'); logger.debug('SPORE UI Framework initialized');
// Note: Navigation is now handled by the app initialization // Note: Navigation is now handled by the app initialization
// to ensure routes are registered before navigation // to ensure routes are registered before navigation
@@ -834,7 +856,7 @@ class App {
// Setup navigation // Setup navigation
setupNavigation() { setupNavigation() {
document.querySelectorAll('.nav-tab').forEach(tab => { document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
tab.addEventListener('click', () => { tab.addEventListener('click', () => {
const routeName = tab.dataset.view; const routeName = tab.dataset.view;
this.navigateTo(routeName); this.navigateTo(routeName);
@@ -844,11 +866,11 @@ class App {
// Clean up cached components (call when app is shutting down) // Clean up cached components (call when app is shutting down)
cleanup() { cleanup() {
console.log('App: Cleaning up cached components...'); logger.debug('App: Cleaning up cached components...');
this.componentCache.forEach((component, routeName) => { this.componentCache.forEach((component, routeName) => {
if (component.isMounted) { if (component.isMounted) {
console.log(`App: Unmounting cached component '${routeName}'`); logger.debug(`App: Unmounting cached component '${routeName}'`);
component.unmount(); component.unmount();
} }
}); });

1
public/scripts/index.js Normal file
View File

@@ -0,0 +1 @@
// intentionally empty placeholder

View File

@@ -26,7 +26,7 @@ class ClusterViewModel extends ViewModel {
// Update cluster members with state preservation // Update cluster members with state preservation
async updateClusterMembers() { async updateClusterMembers() {
try { try {
console.log('ClusterViewModel: updateClusterMembers called'); logger.debug('ClusterViewModel: updateClusterMembers called');
// Store current UI state before update // Store current UI state before update
const currentUIState = this.getAllUIState(); const currentUIState = this.getAllUIState();
@@ -36,9 +36,9 @@ class ClusterViewModel extends ViewModel {
this.set('isLoading', true); this.set('isLoading', true);
this.set('error', null); this.set('error', null);
console.log('ClusterViewModel: Fetching cluster members...'); logger.debug('ClusterViewModel: Fetching cluster members...');
const response = await window.apiClient.getClusterMembers(); const response = await window.apiClient.getClusterMembers();
console.log('ClusterViewModel: Got response:', response); logger.debug('ClusterViewModel: Got response:', response);
const members = response.members || []; const members = response.members || [];
const onlineNodes = Array.isArray(members) const onlineNodes = Array.isArray(members)
@@ -57,7 +57,7 @@ class ClusterViewModel extends ViewModel {
this.set('activeTabs', currentActiveTabs); this.set('activeTabs', currentActiveTabs);
// Update primary node display // Update primary node display
console.log('ClusterViewModel: Updating primary node display...'); logger.debug('ClusterViewModel: Updating primary node display...');
await this.updatePrimaryNodeDisplay(); await this.updatePrimaryNodeDisplay();
} catch (error) { } catch (error) {
@@ -65,7 +65,7 @@ class ClusterViewModel extends ViewModel {
this.set('error', error.message); this.set('error', error.message);
} finally { } finally {
this.set('isLoading', false); this.set('isLoading', false);
console.log('ClusterViewModel: updateClusterMembers completed'); logger.debug('ClusterViewModel: updateClusterMembers completed');
} }
} }
@@ -185,7 +185,7 @@ class ClusterViewModel extends ViewModel {
// Smart update that only updates changed data // Smart update that only updates changed data
async smartUpdate() { async smartUpdate() {
try { try {
console.log('ClusterViewModel: Performing smart update...'); logger.debug('ClusterViewModel: Performing smart update...');
// Fetch new data // Fetch new data
const response = await window.apiClient.getClusterMembers(); const response = await window.apiClient.getClusterMembers();
@@ -193,10 +193,10 @@ class ClusterViewModel extends ViewModel {
// Check if members data has actually changed // Check if members data has actually changed
if (this.hasDataChanged(newMembers, 'members')) { if (this.hasDataChanged(newMembers, 'members')) {
console.log('ClusterViewModel: Members data changed, updating...'); logger.debug('ClusterViewModel: Members data changed, updating...');
await this.updateClusterMembers(); await this.updateClusterMembers();
} else { } else {
console.log('ClusterViewModel: Members data unchanged, skipping update'); logger.debug('ClusterViewModel: Members data unchanged, skipping update');
// Still update primary node display as it might have changed // Still update primary node display as it might have changed
await this.updatePrimaryNodeDisplay(); await this.updatePrimaryNodeDisplay();
} }
@@ -292,7 +292,7 @@ class NodeDetailsViewModel extends ViewModel {
// Set active tab with state persistence // Set active tab with state persistence
setActiveTab(tabName) { setActiveTab(tabName) {
console.log('NodeDetailsViewModel: Setting activeTab to:', tabName); logger.debug('NodeDetailsViewModel: Setting activeTab to:', tabName);
this.set('activeTab', tabName); this.set('activeTab', tabName);
// Store in UI state for persistence // Store in UI state for persistence
@@ -492,14 +492,14 @@ class TopologyViewModel extends ViewModel {
// Update network topology data // Update network topology data
async updateNetworkTopology() { async updateNetworkTopology() {
try { try {
console.log('TopologyViewModel: updateNetworkTopology called'); logger.debug('TopologyViewModel: updateNetworkTopology called');
this.set('isLoading', true); this.set('isLoading', true);
this.set('error', null); this.set('error', null);
// Get cluster members from the primary node // Get cluster members from the primary node
const response = await window.apiClient.getClusterMembers(); const response = await window.apiClient.getClusterMembers();
console.log('TopologyViewModel: Got cluster members response:', response); logger.debug('TopologyViewModel: Got cluster members response:', response);
const members = response.members || []; const members = response.members || [];
@@ -517,7 +517,7 @@ class TopologyViewModel extends ViewModel {
this.set('error', error.message); this.set('error', error.message);
} finally { } finally {
this.set('isLoading', false); this.set('isLoading', false);
console.log('TopologyViewModel: updateNetworkTopology completed'); logger.debug('TopologyViewModel: updateNetworkTopology completed');
} }
} }
@@ -589,7 +589,7 @@ class TopologyViewModel extends ViewModel {
// If no actual connections found, create a basic mesh // If no actual connections found, create a basic mesh
if (links.length === 0) { if (links.length === 0) {
console.log('TopologyViewModel: No actual connections found, creating basic mesh'); logger.debug('TopologyViewModel: No actual connections found, creating basic mesh');
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) { for (let j = i + 1; j < nodes.length; j++) {
const sourceNode = nodes[i]; const sourceNode = nodes[i];

View File

@@ -244,6 +244,7 @@ p {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
opacity: 1; opacity: 1;
z-index: 1; z-index: 1;
-webkit-tap-highlight-color: transparent;
} }
/* Labels */ /* Labels */
@@ -288,6 +289,17 @@ p {
z-index: 2; z-index: 2;
} }
/* Disable hover effects on touch devices to prevent flicker */
@media (hover: none) {
.member-card:hover::before {
opacity: 0 !important;
}
.member-card:hover {
box-shadow: none !important;
z-index: 1 !important;
}
}
.member-card.expanded { .member-card.expanded {
border-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
@@ -3282,4 +3294,170 @@ html {
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
scrollbar-width: thin; /* Thin scrollbar on Firefox */ scrollbar-width: thin; /* Thin scrollbar on Firefox */
scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.1); /* Firefox scrollbar colors */ scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.1); /* Firefox scrollbar colors */
}
/* Mobile width optimization: maximize horizontal space */
@media (max-width: 768px) {
body {
padding: 0.5rem;
}
.container {
padding: 0 0.5rem;
max-height: calc(100vh - 1rem);
}
.cluster-section,
.firmware-section {
padding: 0.5rem;
}
.main-navigation {
padding: 0.25rem;
}
.nav-tab {
padding: 0.6rem 0.8rem;
}
.members-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.5rem;
}
}
@media (max-width: 480px) {
body {
padding: 0.25rem;
}
.container {
padding: 0 0.25rem;
max-height: calc(100vh - 0.5rem);
}
.member-card {
padding: 0.75rem;
}
.main-navigation {
padding: 0.25rem;
}
}
@media (max-width: 768px) {
/* Use single scroll on mobile: let the page/body scroll */
body {
height: auto;
min-height: 100vh;
overflow-y: auto;
}
.container {
max-height: none;
overflow: visible;
}
.view-content,
#cluster-view.active {
max-height: none;
overflow: visible;
}
#cluster-members-container,
#firmware-container,
.firmware-nodes-list {
max-height: none;
overflow: visible;
padding-right: 0;
}
}
@media (max-width: 480px) {
/* Make primary node section more compact */
.cluster-header {
gap: 0.5rem;
padding: 0.5rem 0;
}
.primary-node-info {
gap: 0.35rem;
padding: 0.35rem 0.5rem;
}
.primary-node-label {
font-size: 0.8rem;
}
.primary-node-ip {
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
}
.primary-node-refresh {
padding: 0.3rem;
}
}
/* Reduce tap highlight and flicker in firmware view */
#firmware-view,
.upload-btn,
.upload-btn-compact,
.deploy-btn,
.cap-call-btn,
.progress-refresh-btn,
.clear-btn,
.refresh-btn,
.progress-item,
.result-item,
.file-info {
-webkit-tap-highlight-color: transparent;
}
/* Disable hover-driven animations/effects on touch devices in firmware view */
@media (hover: none) {
#firmware-view .upload-btn:hover,
#firmware-view .upload-btn-compact:hover,
#firmware-view .deploy-btn:hover:not(:disabled),
#firmware-view .progress-refresh-btn:hover,
#firmware-view .cap-call-btn:hover,
#firmware-view .clear-btn:hover,
#firmware-view .refresh-btn:hover,
#firmware-view .progress-item:hover,
#firmware-view .result-item:hover,
#firmware-view .firmware-upload-progress:hover,
#firmware-view .firmware-upload-results:hover,
#firmware-view .file-info:hover {
transform: none !important;
box-shadow: none !important;
}
#firmware-view .progress-item:hover::before,
#firmware-view .result-item:hover::before {
opacity: 0 !important;
}
/* Prevent shimmer animation on deploy button hover */
#firmware-view .deploy-btn:hover:not(:disabled)::before {
left: -100% !important;
}
}
/* Cluster view specific error styling */
#cluster-members-container .error {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.9rem 1.1rem;
margin-top: 0.75rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(244, 67, 54, 0.15) 0%, rgba(244, 67, 54, 0.08) 100%);
border: 1px solid rgba(244, 67, 54, 0.35);
color: #ffcdd2;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
height: auto; /* override global 100% height */
justify-content: flex-start; /* override global centering */
text-align: left; /* ensure left alignment */
}
#cluster-members-container .error::before {
content: '⚠️';
font-size: 1.2rem;
line-height: 1;
flex-shrink: 0;
}
#cluster-members-container .error strong {
color: #ffebee;
font-weight: 700;
margin-right: 0.25rem;
}
#cluster-members-container .error br {
display: none; /* tighten layout by avoiding forced line-breaks */
} }