From 6c58e479affb89bb36e659eadad73cc6672de374 Mon Sep 17 00:00:00 2001 From: master Date: Thu, 28 Aug 2025 10:21:14 +0200 Subject: [PATCH] feature/framework (#1) Reviewed-on: https://git.dcentral.systems/iot/spore-ui/pulls/1 --- README.md | 10 +- docs/FRAMEWORK_README.md | 203 +++ docs/STATE_PRESERVATION.md | 266 ++++ docs/VIEW_SWITCHING_FIXES.md | 223 +++ index.js | 16 + package.json | 2 +- public/api-client.js | 133 ++ public/app.js | 199 +++ public/components.js | 1979 +++++++++++++++++++++++++++ public/debug-cluster-load.html | 190 +++ public/debug-cluster.html | 208 +++ public/debug.html | 124 ++ public/deploy-button-test.html | 344 +++++ public/framework.js | 785 +++++++++++ public/index.html | 14 +- public/script.js | 1263 ----------------- public/simple-test.html | 104 ++ public/styles.css | 444 ++++-- public/test-caching-system.html | 351 +++++ public/test-deploy-button.html | 351 +++++ public/test-framework.html | 131 ++ public/test-refresh.html | 192 +++ public/test-state-preservation.html | 419 ++++++ public/test-tabs.html | 72 + public/test-view-switching.html | 1 + public/view-models.js | 393 ++++++ 26 files changed, 7056 insertions(+), 1361 deletions(-) create mode 100644 docs/FRAMEWORK_README.md create mode 100644 docs/STATE_PRESERVATION.md create mode 100644 docs/VIEW_SWITCHING_FIXES.md create mode 100644 public/api-client.js create mode 100644 public/app.js create mode 100644 public/components.js create mode 100644 public/debug-cluster-load.html create mode 100644 public/debug-cluster.html create mode 100644 public/debug.html create mode 100644 public/deploy-button-test.html create mode 100644 public/framework.js delete mode 100644 public/script.js create mode 100644 public/simple-test.html create mode 100644 public/test-caching-system.html create mode 100644 public/test-deploy-button.html create mode 100644 public/test-framework.html create mode 100644 public/test-refresh.html create mode 100644 public/test-state-preservation.html create mode 100644 public/test-tabs.html create mode 100644 public/test-view-switching.html create mode 100644 public/view-models.js diff --git a/README.md b/README.md index 380a525..5d59a09 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,13 @@ spore-ui/ ├── public/ # Frontend files │ ├── index.html # Main HTML page │ ├── styles.css # All CSS styles -│ ├── script.js # All JavaScript functionality +│ ├── framework.js # Enhanced component framework with state preservation +│ ├── components.js # UI components with partial update support +│ ├── view-models.js # Data models with UI state management +│ ├── app.js # Main application logic +│ └── test-state-preservation.html # Test interface for state preservation +├── docs/ +│ └── STATE_PRESERVATION.md # Detailed documentation of state preservation system └── README.md # This file ``` @@ -40,6 +46,7 @@ spore-ui/ 1. **Install dependencies**: `npm install` 2. **Start the server**: `npm start` 3. **Open in browser**: `http://localhost:3001` +4. **Test state preservation**: `http://localhost:3001/test-state-preservation.html` ## API Endpoints @@ -53,6 +60,7 @@ spore-ui/ - **Backend**: Express.js, Node.js - **Frontend**: Vanilla JavaScript, CSS3, HTML5 +- **Framework**: Custom component-based architecture with state preservation - **API**: SPORE Embedded System API - **Design**: Glassmorphism, CSS Grid, Flexbox diff --git a/docs/FRAMEWORK_README.md b/docs/FRAMEWORK_README.md new file mode 100644 index 0000000..2625104 --- /dev/null +++ b/docs/FRAMEWORK_README.md @@ -0,0 +1,203 @@ +# SPORE UI Framework + +A clean, component-based frontend framework with pub/sub communication and view models. + +## Architecture Overview + +The framework follows a clean architecture pattern with the following layers: + +1. **Components** - Handle UI rendering and user interactions +2. **View Models** - Hold data and business logic +3. **API Client** - Communicate with the backend +4. **Event Bus** - Pub/sub communication between components +5. **Framework Core** - Base classes and application management + +## Key Features + +- **Component-based architecture** - Reusable, self-contained UI components +- **View Models** - Data flows from backend → view model → UI rendering +- **Pub/Sub system** - Components communicate through events +- **Automatic cleanup** - Event listeners and subscriptions are automatically cleaned up +- **Type-safe property access** - View models provide get/set methods with change notifications +- **Routing** - Built-in navigation between views + +## File Structure + +``` +public/ +├── framework.js # Core framework classes +├── api-client.js # Backend API communication +├── view-models.js # View models for each component +├── components.js # UI components +├── app.js # Main application setup +├── index.html # HTML template +└── styles.css # Styling +``` + +## Usage + +### 1. Creating a View Model + +```javascript +class MyViewModel extends ViewModel { + constructor() { + super(); + this.setMultiple({ + data: [], + isLoading: false, + error: null + }); + } + + async loadData() { + try { + this.set('isLoading', true); + const data = await window.apiClient.getData(); + this.set('data', data); + } catch (error) { + this.set('error', error.message); + } finally { + this.set('isLoading', false); + } + } +} +``` + +### 2. Creating a Component + +```javascript +class MyComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + } + + setupEventListeners() { + const button = this.findElement('.my-button'); + if (button) { + this.addEventListener(button, 'click', this.handleClick.bind(this)); + } + } + + setupViewModelListeners() { + this.subscribeToProperty('data', this.render.bind(this)); + this.subscribeToProperty('isLoading', this.render.bind(this)); + this.subscribeToProperty('error', this.render.bind(this)); + } + + render() { + const data = this.viewModel.get('data'); + const isLoading = this.viewModel.get('isLoading'); + const error = this.viewModel.get('error'); + + if (isLoading) { + this.setHTML('', '
Loading...
'); + return; + } + + if (error) { + this.setHTML('', `
${error}
`); + return; + } + + this.setHTML('', `
${this.renderData(data)}
`); + } + + renderData(data) { + return data.map(item => `
${item.name}
`).join(''); + } + + handleClick() { + // Handle user interaction + this.viewModel.loadData(); + } +} +``` + +### 3. Using the Event Bus + +```javascript +// Subscribe to events +this.subscribeToEvent('user-logged-in', (userData) => { + console.log('User logged in:', userData); +}); + +// Publish events +this.viewModel.publish('data-updated', { timestamp: Date.now() }); +``` + +### 4. Component Helper Methods + +The framework provides several helper methods for common DOM operations: + +```javascript +// Find elements +const element = this.findElement('.my-class'); +const elements = this.findAllElements('.my-class'); + +// Update content +this.setHTML('.my-container', '
New content
'); +this.setText('.my-text', 'New text'); + +// Manage classes +this.setClass('.my-element', 'active', true); +this.setClass('.my-element', 'hidden', false); + +// Show/hide elements +this.setVisible('.my-element', false); +this.setEnabled('.my-button', false); + +// Manage styles +this.setStyle('.my-element', 'color', 'red'); +``` + +### 5. Registering Routes + +```javascript +// In app.js +window.app.registerRoute('my-view', MyComponent, 'my-view-container'); +``` + +### 6. Navigation + +```javascript +// Navigate to a route +window.app.navigateTo('my-view'); +``` + +## Data Flow + +1. **User Interaction** → Component handles event +2. **Component** → Calls view model method +3. **View Model** → Makes API call or processes data +4. **View Model** → Updates properties (triggers change notifications) +5. **Component** → Receives change notification and re-renders +6. **UI** → Updates to reflect new data + +## Best Practices + +1. **Keep components focused** - Each component should have a single responsibility +2. **Use view models for business logic** - Don't put API calls or data processing in components +3. **Subscribe to specific properties** - Only listen to properties your component needs +4. **Use the event bus sparingly** - Prefer direct view model communication for related components +5. **Clean up resources** - The framework handles most cleanup automatically, but be mindful of custom event listeners +6. **Error handling** - Always handle errors in async operations and update the view model accordingly + +## Migration from Old Code + +The old monolithic `script.js` has been broken down into: + +- **API calls** → `api-client.js` +- **Data management** → `view-models.js` +- **UI logic** → `components.js` +- **Application setup** → `app.js` + +Each piece is now more focused, testable, and maintainable. + +## Benefits + +- **Maintainability** - Smaller, focused files are easier to understand and modify +- **Testability** - View models can be tested independently of UI +- **Reusability** - Components can be reused across different views +- **Scalability** - Easy to add new features without affecting existing code +- **Debugging** - Clear separation of concerns makes issues easier to track down +- **Performance** - Components only re-render when their specific data changes \ No newline at end of file diff --git a/docs/STATE_PRESERVATION.md b/docs/STATE_PRESERVATION.md new file mode 100644 index 0000000..18b9577 --- /dev/null +++ b/docs/STATE_PRESERVATION.md @@ -0,0 +1,266 @@ +# 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 \ No newline at end of file diff --git a/docs/VIEW_SWITCHING_FIXES.md b/docs/VIEW_SWITCHING_FIXES.md new file mode 100644 index 0000000..0ad35e8 --- /dev/null +++ b/docs/VIEW_SWITCHING_FIXES.md @@ -0,0 +1,223 @@ +# 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 \ No newline at end of file diff --git a/index.js b/index.js index eda1bca..53b5e15 100644 --- a/index.js +++ b/index.js @@ -357,6 +357,22 @@ app.get('/api/cluster/members', async (req, res) => { // API endpoint to get task status app.get('/api/tasks/status', async (req, res) => { try { + const { ip } = req.query; + + if (ip) { + try { + const nodeClient = new SporeApiClient(`http://${ip}`); + const taskStatus = await nodeClient.getTaskStatus(); + return res.json(taskStatus); + } catch (innerError) { + console.error('Error fetching task status from specific node:', innerError); + return res.status(500).json({ + error: 'Failed to fetch task status from node', + message: innerError.message + }); + } + } + if (!sporeClient) { return res.status(503).json({ error: 'Service unavailable', diff --git a/package.json b/package.json index ea4a4e6..590f7ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spore-ui", "version": "1.0.0", - "description": "## TODO", + "description": "SPORE Cluster Management UI", "main": "index.js", "scripts": { "start": "node index.js", diff --git a/public/api-client.js b/public/api-client.js new file mode 100644 index 0000000..67a261c --- /dev/null +++ b/public/api-client.js @@ -0,0 +1,133 @@ +// API Client for communicating with the backend + +class ApiClient { + constructor() { + this.baseUrl = 'http://localhost:3001'; // Backend server URL + } + + async getClusterMembers() { + try { + const response = await fetch(`${this.baseUrl}/api/cluster/members`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + throw new Error(`Request failed: ${error.message}`); + } + } + + async getDiscoveryInfo() { + try { + const response = await fetch(`${this.baseUrl}/api/discovery/nodes`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + throw new Error(`Request failed: ${error.message}`); + } + } + + async selectRandomPrimaryNode() { + try { + const response = await fetch(`${this.baseUrl}/api/discovery/random-primary`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + timestamp: new Date().toISOString() + }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + throw new Error(`Request failed: ${error.message}`); + } + } + + async getNodeStatus(ip) { + try { + const response = await fetch(`${this.baseUrl}/api/node/status/${encodeURIComponent(ip)}`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + throw new Error(`Request failed: ${error.message}`); + } + } + + async getTasksStatus(ip) { + try { + const url = ip + ? `${this.baseUrl}/api/tasks/status?ip=${encodeURIComponent(ip)}` + : `${this.baseUrl}/api/tasks/status`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + throw new Error(`Request failed: ${error.message}`); + } + } + + async uploadFirmware(file, nodeIp) { + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${this.baseUrl}/api/node/update?ip=${encodeURIComponent(nodeIp)}`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + throw new Error(`Upload failed: ${error.message}`); + } + } +} + +// Global API client instance +window.apiClient = new ApiClient(); \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7fe5b8a --- /dev/null +++ b/public/app.js @@ -0,0 +1,199 @@ +// Main SPORE UI Application + +// Initialize the application when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + console.log('=== SPORE UI Application Initialization ==='); + + // Initialize the framework (but don't navigate yet) + console.log('App: Creating framework instance...'); + const app = window.app; + + // Create view models + console.log('App: Creating view models...'); + const clusterViewModel = new ClusterViewModel(); + const firmwareViewModel = new FirmwareViewModel(); + console.log('App: View models created:', { clusterViewModel, firmwareViewModel }); + + // Connect firmware view model to cluster data + clusterViewModel.subscribe('members', (members) => { + console.log('App: Members subscription triggered:', members); + if (members && members.length > 0) { + // Extract node information for firmware view + const nodes = members.map(member => ({ + ip: member.ip, + hostname: member.hostname || member.ip + })); + firmwareViewModel.updateAvailableNodes(nodes); + console.log('App: Updated firmware view model with nodes:', nodes); + } else { + firmwareViewModel.updateAvailableNodes([]); + console.log('App: Cleared firmware view model nodes'); + } + }); + + // Register routes with their view models + console.log('App: Registering routes...'); + app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel); + app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel); + console.log('App: Routes registered and components pre-initialized'); + + // Initialize cluster status component for header badge AFTER main components + // DISABLED - causes interference with main cluster functionality + /* + console.log('App: Initializing cluster status component...'); + const clusterStatusComponent = new ClusterStatusComponent( + document.querySelector('.cluster-status'), + clusterViewModel, + app.eventBus + ); + clusterStatusComponent.initialize(); + console.log('App: Cluster status component initialized'); + */ + + // Set up navigation event listeners + console.log('App: Setting up navigation...'); + 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 + console.log('App: Navigating to default route...'); + app.navigateTo('cluster'); + + console.log('=== SPORE UI Application initialization completed ==='); +}); + +// Set up periodic updates with state preservation +function setupPeriodicUpdates() { + // Auto-refresh cluster members every 30 seconds using smart update + setInterval(() => { + if (window.app.currentView && window.app.currentView.viewModel) { + const viewModel = window.app.currentView.viewModel; + + // Use smart update if available, otherwise fall back to regular update + if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') { + console.log('App: Performing smart update to preserve UI state...'); + viewModel.smartUpdate(); + } else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') { + console.log('App: Performing regular update...'); + viewModel.updateClusterMembers(); + } + } + }, 30000); + + // Update primary node display every 10 seconds (this is lightweight and doesn't affect UI state) + setInterval(() => { + if (window.app.currentView && window.app.currentView.viewModel) { + const viewModel = window.app.currentView.viewModel; + if (viewModel.updatePrimaryNodeDisplay && typeof viewModel.updatePrimaryNodeDisplay === 'function') { + viewModel.updatePrimaryNodeDisplay(); + } + } + }, 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 +window.addEventListener('error', function(event) { + console.error('Global error:', event.error); +}); + +// Global unhandled promise rejection handler +window.addEventListener('unhandledrejection', function(event) { + console.error('Unhandled promise rejection:', event.reason); +}); + +// Clean up on page unload +window.addEventListener('beforeunload', function() { + if (window.app) { + console.log('App: Cleaning up cached components...'); + window.app.cleanup(); + } +}); \ No newline at end of file diff --git a/public/components.js b/public/components.js new file mode 100644 index 0000000..c7d4411 --- /dev/null +++ b/public/components.js @@ -0,0 +1,1979 @@ +// SPORE UI Components + +// 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('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 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 = 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) { + console.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); + } + } +} + +// Cluster Members Component with enhanced state preservation +class ClusterMembersComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + console.log('ClusterMembersComponent: Constructor called'); + console.log('ClusterMembersComponent: Container:', container); + console.log('ClusterMembersComponent: Container ID:', container?.id); + console.log('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) { + console.log('ClusterMembersComponent: Performing initial render check'); + this.render(); + } + }, 200); + } + + mount() { + console.log('ClusterMembersComponent: Starting mount...'); + super.mount(); + + // Show loading state immediately when mounted + console.log('ClusterMembersComponent: Showing initial loading state'); + this.showLoadingState(); + + // Set up loading timeout safeguard + this.setupLoadingTimeout(); + + console.log('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) { + console.warn('ClusterMembersComponent: Loading timeout reached, forcing render check'); + this.forceRenderCheck(); + } + }, 10000); // 10 second timeout + } + + // Force a render check when loading gets stuck + forceRenderCheck() { + console.log('ClusterMembersComponent: Force render check called'); + const members = this.viewModel.get('members'); + const error = this.viewModel.get('error'); + const isLoading = this.viewModel.get('isLoading'); + + console.log('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() { + console.log('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() { + console.log('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)); + console.log('ClusterMembersComponent: View model listeners set up'); + } + + // Handle members update with state preservation + handleMembersUpdate(newMembers, previousMembers) { + console.log('ClusterMembersComponent: Members updated:', { newMembers, previousMembers }); + + // Prevent multiple simultaneous renders + if (this.renderInProgress) { + console.log('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) { + console.log('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) { + console.log('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 + console.log('ClusterMembersComponent: Preserving state, performing partial update'); + this.updateMembersPartially(newMembers, previousMembers); + } else { + // Full re-render if structure changed significantly + console.log('ClusterMembersComponent: Structure changed, performing full re-render'); + this.render(); + } + } + + // Handle loading state update + handleLoadingUpdate(isLoading) { + console.log('ClusterMembersComponent: Loading state changed:', isLoading); + + if (isLoading) { + console.log('ClusterMembersComponent: Showing loading state'); + this.showLoadingState(); + + // Set up a loading completion check + this.checkLoadingCompletion(); + } else { + console.log('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'); + + console.log('ClusterMembersComponent: Handling loading completion:', { members, error, isLoading }); + + if (error) { + console.log('ClusterMembersComponent: Loading completed with error, showing error state'); + this.showErrorState(error); + } else if (members && members.length > 0) { + console.log('ClusterMembersComponent: Loading completed with data, rendering members'); + this.renderMembers(members); + } else if (!isLoading) { + console.log('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) { + console.log('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() { + // Skip rendering if we're in the middle of a view switch + const isViewSwitching = document.querySelectorAll('.view-content.active').length === 0; + if (isViewSwitching) { + console.log('ClusterMembersComponent: View switching in progress, skipping render'); + return true; + } + + // Skip rendering if the component is not visible + const isVisible = this.container.style.display !== 'none' && + this.container.style.opacity !== '0' && + this.container.classList.contains('active'); + if (!isVisible) { + console.log('ClusterMembersComponent: Component not visible, skipping render'); + return true; + } + + return false; + } + + // Update members partially to preserve UI state + updateMembersPartially(newMembers, previousMembers) { + console.log('ClusterMembersComponent: Performing partial update to preserve UI state'); + + // Update only the data that changed, preserving expanded states and active tabs + newMembers.forEach((newMember, index) => { + const prevMember = previousMembers[index]; + if (prevMember && this.hasMemberChanged(newMember, prevMember)) { + this.updateMemberCard(newMember, index); + } + }); + } + + // 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, index) { + 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 statusText = member.status === 'active' ? 'Online' : 'Offline'; + const statusIcon = member.status === 'active' ? '🟢' : '🔴'; + + statusElement.className = `member-status ${statusClass}`; + statusElement.innerHTML = `${statusIcon} ${statusText}`; + } + + // 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-name'); + if (hostnameElement && member.hostname !== hostnameElement.textContent) { + hostnameElement.textContent = member.hostname || 'Unknown Device'; + } + } + + render() { + if (this.renderInProgress) { + console.log('ClusterMembersComponent: Render already in progress, skipping'); + return; + } + + // Check if we should skip rendering during view switches + if (this.shouldSkipRender()) { + return; + } + + this.renderInProgress = true; + + try { + console.log('ClusterMembersComponent: render() called'); + console.log('ClusterMembersComponent: Container element:', this.container); + console.log('ClusterMembersComponent: Is mounted:', this.isMounted); + + const members = this.viewModel.get('members'); + const isLoading = this.viewModel.get('isLoading'); + const error = this.viewModel.get('error'); + + console.log('ClusterMembersComponent: render data:', { members, isLoading, error }); + + if (isLoading) { + console.log('ClusterMembersComponent: Showing loading state'); + this.showLoadingState(); + return; + } + + if (error) { + console.log('ClusterMembersComponent: Showing error state'); + this.showErrorState(error); + return; + } + + if (!members || members.length === 0) { + console.log('ClusterMembersComponent: Showing empty state'); + this.showEmptyState(); + return; + } + + console.log('ClusterMembersComponent: Rendering members:', members); + this.renderMembers(members); + + } finally { + this.renderInProgress = false; + } + } + + // Show loading state + showLoadingState() { + console.log('ClusterMembersComponent: showLoadingState() called'); + this.setHTML('', ` +
+
Loading cluster members...
+
+ `); + } + + // Show error state + showErrorState(error) { + console.log('ClusterMembersComponent: showErrorState() called with error:', error); + this.setHTML('', ` +
+ Error loading cluster members:
+ ${error} +
+ `); + } + + // Show empty state + showEmptyState() { + console.log('ClusterMembersComponent: showEmptyState() called'); + this.setHTML('', ` +
+
🌐
+
No cluster members found
+
+ The cluster might be empty or not yet discovered +
+
+ `); + } + + renderMembers(members) { + console.log('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' ? '🟢' : '🔴'; + + console.log('ClusterMembersComponent: Rendering member:', member); + + return ` +
+
+
+
${member.hostname || 'Unknown Device'}
+
${member.ip || 'No IP'}
+
+ ${statusIcon} ${statusText} +
+
+ Latency: + ${member.latency ? member.latency + 'ms' : 'N/A'} +
+
+
+ + + +
+
+
+
Loading detailed information...
+
+
+ `; + }).join(''); + + console.log('ClusterMembersComponent: Setting HTML, length:', membersHTML.length); + this.setHTML('', membersHTML); + console.log('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 = '
Loading detailed information...
'; + + // 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) { + console.error('Failed to expand card:', error); + memberDetails.innerHTML = ` +
+ Error loading node details:
+ ${error.message} +
+ `; + } + } + + collapseCard(card, expandIcon) { + card.classList.remove('expanded'); + if (expandIcon) { + expandIcon.classList.remove('expanded'); + } + } + + setupTabs(container) { + const tabButtons = container.querySelectorAll('.tab-button'); + const tabContents = container.querySelectorAll('.tab-content'); + + tabButtons.forEach(button => { + this.addEventListener(button, 'click', (e) => { + e.stopPropagation(); + + const targetTab = button.dataset.tab; + + // 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 clicked button and corresponding content + button.classList.add('active'); + const targetContent = container.querySelector(`#${targetTab}-tab`); + if (targetContent) { + targetContent.classList.add('active'); + } + + // Store active tab state + const memberCard = container.closest('.member-card'); + if (memberCard) { + const memberIp = memberCard.dataset.memberIp; + this.viewModel.storeActiveTab(memberIp, targetTab); + } + }); + }); + + // Also prevent event propagation on tab content areas + tabContents.forEach(content => { + this.addEventListener(content, 'click', (e) => { + e.stopPropagation(); + }); + }); + } + + // 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'); + + console.log('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() { + console.log('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(); + + console.log('ClusterMembersComponent: Manual refresh completed'); + } catch (error) { + console.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(); + + console.log(`${this.constructor.name} unmounted`); + } + + // Override pause method to handle timeouts and operations + onPause() { + console.log('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() { + console.log('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) { + console.log('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; + } +} + +// 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)); + } + + // Handle node status update with state preservation + handleNodeStatusUpdate(newStatus, previousStatus) { + if (newStatus && !this.viewModel.get('isLoading')) { + this.renderNodeDetails(newStatus, this.viewModel.get('tasks')); + } + } + + // 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); + } + } + + // Handle loading state update + handleLoadingUpdate(isLoading) { + if (isLoading) { + this.setHTML('', '
Loading detailed information...
'); + } + } + + // Handle error state update + handleErrorUpdate(error) { + if (error) { + this.setHTML('', ` +
+ Error loading node details:
+ ${error} +
+ `); + } + } + + // Handle active tab update + handleActiveTabUpdate(newTab, previousTab) { + // Update tab UI without full re-render + this.updateActiveTab(newTab, previousTab); + } + + 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'); + + if (isLoading) { + this.setHTML('', '
Loading detailed information...
'); + return; + } + + if (error) { + this.setHTML('', ` +
+ Error loading node details:
+ ${error} +
+ `); + return; + } + + if (!nodeStatus) { + this.setHTML('', '
No node status available
'); + return; + } + + this.renderNodeDetails(nodeStatus, tasks); + } + + renderNodeDetails(nodeStatus, tasks) { + // Always start with 'status' tab, don't restore previous state + const activeTab = 'status'; + console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab); + + const html = ` +
+
+ + + + +
+ +
+
+ Free Heap: + ${Math.round(nodeStatus.freeHeap / 1024)}KB +
+
+ Chip ID: + ${nodeStatus.chipId} +
+
+ SDK Version: + ${nodeStatus.sdkVersion} +
+
+ CPU Frequency: + ${nodeStatus.cpuFreqMHz}MHz +
+
+ Flash Size: + ${Math.round(nodeStatus.flashChipSize / 1024)}KB +
+
+ +
+

Available API Endpoints:

+ ${nodeStatus.api ? nodeStatus.api.map(endpoint => + `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` + ).join('') : '
No API endpoints available
'} +
+ +
+ ${this.renderTasksTab(tasks)} +
+ +
+ ${this.renderFirmwareTab()} +
+
+ `; + + this.setHTML('', html); + this.setupTabs(); + this.setupFirmwareUpload(); + } + + renderTasksTab(tasks) { + if (tasks && tasks.length > 0) { + const tasksHTML = tasks.map(task => ` +
+
+ ${task.name || 'Unknown Task'} + + ${task.running ? '🟢 Running' : '🔴 Stopped'} + +
+
+ Interval: ${task.interval}ms + ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'} +
+
+ `).join(''); + + return ` +

Active Tasks

+ ${tasksHTML} + `; + } else { + return ` +
+
📋 No active tasks found
+
+ This node has no running tasks +
+
+ `; + } + } + + renderFirmwareTab() { + return ` +
+

Firmware Update

+
+ + +
Select a .bin or .hex file to upload
+ +
+
+ `; + } + + setupTabs() { + console.log('NodeDetailsComponent: Setting up tabs'); + const tabButtons = this.findAllElements('.tab-button'); + const tabContents = this.findAllElements('.tab-content'); + + tabButtons.forEach(button => { + this.addEventListener(button, 'click', (e) => { + e.stopPropagation(); + + const targetTab = button.dataset.tab; + console.log('NodeDetailsComponent: Tab clicked:', targetTab); + + // Update tab UI locally, don't store in view model + this.updateActiveTab(targetTab); + }); + }); + + // Also prevent event propagation on tab content areas + tabContents.forEach(content => { + this.addEventListener(content, 'click', (e) => { + e.stopPropagation(); + }); + }); + } + + // Update active tab without full re-render + updateActiveTab(newTab, previousTab = null) { + const tabButtons = this.findAllElements('.tab-button'); + const tabContents = this.findAllElements('.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 new active tab + 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'); + + console.log(`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 = ` +
+
📤 Uploading ${file.name}...
+
Size: ${(file.size / 1024).toFixed(1)}KB
+
+ `; + + // 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 = ` +
+
✅ Firmware uploaded successfully!
+
Node: ${memberIp}
+
Size: ${(file.size / 1024).toFixed(1)}KB
+
+ `; + + console.log('Firmware upload successful:', result); + + } catch (error) { + console.error('Firmware upload failed:', error); + + // Show error + uploadStatus.innerHTML = ` +
+
❌ Upload failed: ${error.message}
+
+ `; + } finally { + // Re-enable upload button + uploadBtn.disabled = false; + uploadBtn.textContent = originalText; + + // Clear file input + const fileInput = this.findElement('#firmware-file'); + if (fileInput) { + fileInput.value = ''; + } + } + } +} + +// Firmware Component +class FirmwareComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + console.log('FirmwareComponent: Constructor called'); + console.log('FirmwareComponent: Container:', container); + console.log('FirmwareComponent: Container ID:', container?.id); + + // Check if the dropdown exists in the container + if (container) { + const dropdown = container.querySelector('#specific-node-select'); + console.log('FirmwareComponent: Dropdown found in constructor:', !!dropdown); + if (dropdown) { + console.log('FirmwareComponent: Dropdown tagName:', dropdown.tagName); + console.log('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'); + console.log('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); + if (specificNodeSelect) { + console.log('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); + console.log('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); + console.log('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); + console.log('FirmwareComponent: Event listener added to specificNodeSelect'); + } else { + console.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); + } + + // 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.subscribeToProperty('specificNode', this.updateDeployButton.bind(this)); + this.subscribeToProperty('availableNodes', () => { + this.populateNodeSelect(); + this.updateDeployButton(); + }); + this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this)); + this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); + this.subscribeToProperty('isUploading', this.updateUploadState.bind(this)); + } + + mount() { + super.mount(); + + console.log('FirmwareComponent: Mounting...'); + + // Check if the dropdown exists when mounted + const dropdown = this.findElement('#specific-node-select'); + console.log('FirmwareComponent: Mount - dropdown found:', !!dropdown); + if (dropdown) { + console.log('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); + console.log('FirmwareComponent: Mount - dropdown id:', dropdown.id); + console.log('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); + } + + console.log('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; + console.log('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); + console.log('Event:', event); + console.log('Event target:', event.target); + console.log('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 { + await this.uploadToSpecificNode(file, specificNode); + } + + // Reset interface after successful upload + this.viewModel.resetUploadState(); + + } catch (error) { + console.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) { + console.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) { + console.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 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) { + console.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 = ` +
+
+

📤 Firmware Upload Progress

+
+ File: ${file.name} + Size: ${(file.size / 1024).toFixed(1)}KB + Targets: ${nodes.length} node(s) +
+
+
+
+
+ 0/${nodes.length} Successful (0%) +
+
+ Status: Preparing upload... +
+
+
+ ${nodes.map(node => ` +
+
+ ${node.hostname || node.ip} + ${node.ip} +
+
Pending...
+
+
+ `).join('')} +
+
+ `; + + 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 = 'Status: Upload completed successfully'; + } else if (successfulUploads === 0) { + progressSummary.innerHTML = 'Status: Upload failed'; + } + } + } + } + + 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 = `✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}`; + } else { + progressHeader.textContent = `📤 Firmware Upload Failed`; + progressSummary.innerHTML = `❌ Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}`; + } + } else if (successCount === totalCount) { + // Multi-node upload - all successful + progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`; + progressSummary.innerHTML = `✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}`; + } else { + // Multi-node upload - some failed + progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`; + progressSummary.innerHTML = `⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`; + } + } + } + + 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'); + + console.log('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); + + if (targetType === 'specific') { + specificNodeSelect.style.visibility = 'visible'; + specificNodeSelect.style.opacity = '1'; + console.log('FirmwareComponent: Showing specific node select'); + + // Check if the dropdown exists and is ready + if (specificNodeSelect && specificNodeSelect.tagName === 'SELECT') { + console.log('FirmwareComponent: Dropdown is ready, populating immediately'); + this.populateNodeSelect(); + } else { + console.log('FirmwareComponent: Dropdown not ready, delaying population'); + // Small delay to ensure the dropdown is visible before populating + setTimeout(() => { + this.populateNodeSelect(); + }, 100); + } + } else { + specificNodeSelect.style.visibility = 'hidden'; + specificNodeSelect.style.opacity = '0'; + console.log('FirmwareComponent: Hiding specific node select'); + } + + 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) { + console.warn('FirmwareComponent: populateNodeSelect - select element not found'); + return; + } + + if (select.tagName !== 'SELECT') { + console.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); + return; + } + + console.log('FirmwareComponent: populateNodeSelect called'); + console.log('FirmwareComponent: Select element:', select); + console.log('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes')); + + // Clear existing options + select.innerHTML = ''; + + // 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); + + console.log('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); + + console.log('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(); + } +} + +// Cluster View Component +class ClusterViewComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + console.log('ClusterViewComponent: Constructor called'); + console.log('ClusterViewComponent: Container:', container); + console.log('ClusterViewComponent: Container ID:', container?.id); + + // Find elements for sub-components + const primaryNodeContainer = this.findElement('.primary-node-info'); + const clusterMembersContainer = this.findElement('#cluster-members-container'); + + console.log('ClusterViewComponent: Primary node container:', primaryNodeContainer); + console.log('ClusterViewComponent: Cluster members container:', clusterMembersContainer); + console.log('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id); + console.log('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML); + + // Create sub-components + this.primaryNodeComponent = new PrimaryNodeComponent( + primaryNodeContainer, + viewModel, + eventBus + ); + + this.clusterMembersComponent = new ClusterMembersComponent( + clusterMembersContainer, + viewModel, + eventBus + ); + + console.log('ClusterViewComponent: Sub-components created'); + + // Track if we've already loaded data to prevent unnecessary reloads + this.dataLoaded = false; + } + + mount() { + console.log('ClusterViewComponent: Mounting...'); + super.mount(); + + console.log('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 = !this.dataLoaded || !members || members.length === 0; + + if (shouldLoadData) { + console.log('ClusterViewComponent: Starting initial data load...'); + // Initial data load - ensure it happens after mounting + setTimeout(() => { + this.viewModel.updateClusterMembers().then(() => { + this.dataLoaded = true; + }).catch(error => { + console.error('ClusterViewComponent: Failed to load initial data:', error); + }); + }, 100); + } else { + console.log('ClusterViewComponent: Data already loaded, skipping initial load'); + } + + // Set up periodic updates + // this.setupPeriodicUpdates(); // Disabled automatic refresh + console.log('ClusterViewComponent: Mounted successfully'); + } + + setupRefreshButton() { + console.log('ClusterViewComponent: Setting up refresh button...'); + + const refreshBtn = this.findElement('.refresh-btn'); + console.log('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn); + + if (refreshBtn) { + console.log('ClusterViewComponent: Adding click event listener to refresh button'); + this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this)); + console.log('ClusterViewComponent: Event listener added successfully'); + } else { + console.error('ClusterViewComponent: Refresh button not found!'); + console.log('ClusterViewComponent: Container HTML:', this.container.innerHTML); + console.log('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button')); + } + } + + async handleRefresh() { + console.log('ClusterViewComponent: Refresh button clicked, performing full refresh...'); + + // Get the refresh button and show loading state + const refreshBtn = this.findElement('.refresh-btn'); + console.log('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn); + + if (refreshBtn) { + const originalText = refreshBtn.innerHTML; + console.log('ClusterViewComponent: Original button text:', originalText); + + refreshBtn.innerHTML = ` + + + + + Refreshing... + `; + refreshBtn.disabled = true; + + try { + console.log('ClusterViewComponent: Starting cluster members update...'); + // Always perform a full refresh when user clicks refresh button + await this.viewModel.updateClusterMembers(); + console.log('ClusterViewComponent: Cluster members update completed successfully'); + } catch (error) { + console.error('ClusterViewComponent: Error during refresh:', error); + // Show error state + if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { + this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); + } + } finally { + console.log('ClusterViewComponent: Restoring button state...'); + // Restore button state + refreshBtn.innerHTML = originalText; + refreshBtn.disabled = false; + } + } else { + console.warn('ClusterViewComponent: Refresh button not found, using fallback refresh'); + // Fallback if button not found + try { + await this.viewModel.updateClusterMembers(); + } catch (error) { + console.error('ClusterViewComponent: Fallback refresh failed:', error); + if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) { + this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed'); + } + } + } + } + + unmount() { + console.log('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(); + console.log('ClusterViewComponent: Unmounted'); + } + + // Override pause method to handle sub-components + onPause() { + console.log('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() { + console.log('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); + } +} + +// Firmware View Component +class FirmwareViewComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + console.log('FirmwareViewComponent: Constructor called'); + console.log('FirmwareViewComponent: Container:', container); + + const firmwareContainer = this.findElement('#firmware-container'); + console.log('FirmwareViewComponent: Firmware container found:', !!firmwareContainer); + + this.firmwareComponent = new FirmwareComponent( + firmwareContainer, + viewModel, + eventBus + ); + + console.log('FirmwareViewComponent: FirmwareComponent created'); + } + + mount() { + super.mount(); + + console.log('FirmwareViewComponent: Mounting...'); + + // Mount sub-component + this.firmwareComponent.mount(); + + // Update available nodes + this.updateAvailableNodes(); + + console.log('FirmwareViewComponent: Mounted successfully'); + } + + unmount() { + // Unmount sub-component + if (this.firmwareComponent) { + this.firmwareComponent.unmount(); + } + + super.unmount(); + } + + // Override pause method to handle sub-components + onPause() { + console.log('FirmwareViewComponent: Pausing...'); + + // Pause sub-component + if (this.firmwareComponent && this.firmwareComponent.isMounted) { + this.firmwareComponent.pause(); + } + } + + // Override resume method to handle sub-components + onResume() { + console.log('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 { + console.log('FirmwareViewComponent: updateAvailableNodes called'); + const response = await window.apiClient.getClusterMembers(); + const nodes = response.members || []; + console.log('FirmwareViewComponent: Got nodes:', nodes); + this.viewModel.updateAvailableNodes(nodes); + console.log('FirmwareViewComponent: Available nodes updated in view model'); + } catch (error) { + console.error('Failed to update available nodes:', error); + } + } +} + +// 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); + } + } +} \ No newline at end of file diff --git a/public/debug-cluster-load.html b/public/debug-cluster-load.html new file mode 100644 index 0000000..c335d0f --- /dev/null +++ b/public/debug-cluster-load.html @@ -0,0 +1,190 @@ + + + + + + Debug Cluster Load + + + +

🔍 Debug Cluster Load

+ +
+

Debug Controls

+ + + + + +
+ +
+

Container Elements

+
+
+

Primary Node

+
🔍 Discovering...
+ +
+ +
+

Cluster Members

+
Loading cluster members...
+
+
+
+ +
+

Debug Log

+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/debug-cluster.html b/public/debug-cluster.html new file mode 100644 index 0000000..a26c364 --- /dev/null +++ b/public/debug-cluster.html @@ -0,0 +1,208 @@ + + + + + + Debug Cluster + + + +

🐛 Debug Cluster Functionality

+ +
+

1. API Client Test

+ +
+
+ +
+

2. View Model Test

+ +
+
+ +
+

3. Component Test

+ +
+
+ +
+

4. Console Log

+
+ +
+ + + + + + + + + \ No newline at end of file diff --git a/public/debug.html b/public/debug.html new file mode 100644 index 0000000..ce1bce9 --- /dev/null +++ b/public/debug.html @@ -0,0 +1,124 @@ + + + + + + Debug Framework + + + +

Framework Debug

+ +
+

Console Log

+
+ +
+ +
+

Test Cluster View

+
+
+
+
+
+ Primary Node: + Discovering... + +
+
+ +
+
+
Loading cluster members...
+
+
+
+ +
+ + + + + + + + + \ No newline at end of file diff --git a/public/deploy-button-test.html b/public/deploy-button-test.html new file mode 100644 index 0000000..74bcd4a --- /dev/null +++ b/public/deploy-button-test.html @@ -0,0 +1,344 @@ + + + + + + Deploy Button Test - Isolated + + + +

🚀 Deploy Button Test - Isolated

+ +
+

Test Scenario: Deploy Button State

+

This test isolates the deploy button functionality to debug the issue.

+
+ +
+

🚀 Firmware Update

+ +
+ + +
+ +
+ + + No file selected +
+ + +
+ +
+

Cluster Members

+
+
Loading cluster members...
+
+ + +
+ +
+

Debug Information

+
Waiting for actions...
+
+ + + + \ No newline at end of file diff --git a/public/framework.js b/public/framework.js new file mode 100644 index 0000000..25d9715 --- /dev/null +++ b/public/framework.js @@ -0,0 +1,785 @@ +// SPORE UI Framework - Component-based architecture with pub/sub system + +// Event Bus for pub/sub communication +class EventBus { + constructor() { + this.events = new Map(); + } + + // Subscribe to an event + subscribe(event, callback) { + if (!this.events.has(event)) { + this.events.set(event, []); + } + this.events.get(event).push(callback); + + // Return unsubscribe function + return () => { + const callbacks = this.events.get(event); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + }; + } + + // Publish an event + publish(event, data) { + if (this.events.has(event)) { + this.events.get(event).forEach(callback => { + try { + callback(data); + } catch (error) { + console.error(`Error in event callback for ${event}:`, error); + } + }); + } + } + + // Unsubscribe from an event + unsubscribe(event, callback) { + if (this.events.has(event)) { + const callbacks = this.events.get(event); + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + } + + // Clear all events + clear() { + this.events.clear(); + } +} + +// Base ViewModel class with enhanced state management +class ViewModel { + constructor() { + this._data = {}; + this._listeners = new Map(); + this._eventBus = null; + this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc. + this._previousData = {}; // Store previous data for change detection + } + + // Set the event bus for this view model + setEventBus(eventBus) { + this._eventBus = eventBus; + } + + // Get data property + get(property) { + return this._data[property]; + } + + // Set data property and notify listeners + set(property, value) { + console.log(`ViewModel: Setting property '${property}' to:`, value); + + // Check if the value has actually changed + const hasChanged = this._data[property] !== value; + + if (hasChanged) { + // Store previous value for change detection + this._previousData[property] = this._data[property]; + + // Update the data + this._data[property] = value; + + console.log(`ViewModel: Property '${property}' changed, notifying listeners...`); + this._notifyListeners(property, value, this._previousData[property]); + } else { + console.log(`ViewModel: Property '${property}' unchanged, skipping notification`); + } + } + + // Set multiple properties at once with change detection + setMultiple(properties) { + const changedProperties = {}; + const unchangedProperties = {}; + + Object.keys(properties).forEach(key => { + if (this._data[key] !== properties[key]) { + changedProperties[key] = properties[key]; + } else { + unchangedProperties[key] = properties[key]; + } + }); + + // Set all properties + Object.keys(properties).forEach(key => { + this._data[key] = properties[key]; + }); + + // Notify listeners only for changed properties + Object.keys(changedProperties).forEach(key => { + this._notifyListeners(key, changedProperties[key], this._previousData[key]); + }); + + if (Object.keys(changedProperties).length > 0) { + console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties)); + } + } + + // Subscribe to property changes + subscribe(property, callback) { + if (!this._listeners.has(property)) { + this._listeners.set(property, []); + } + this._listeners.get(property).push(callback); + + // Return unsubscribe function + return () => { + const callbacks = this._listeners.get(property); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + }; + } + + // Notify listeners of property changes + _notifyListeners(property, value, previousValue) { + console.log(`ViewModel: _notifyListeners called for property '${property}'`); + if (this._listeners.has(property)) { + const callbacks = this._listeners.get(property); + console.log(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`); + callbacks.forEach((callback, index) => { + try { + console.log(`ViewModel: Calling listener ${index} for property '${property}'`); + callback(value, previousValue); + } catch (error) { + console.error(`Error in property listener for ${property}:`, error); + } + }); + } else { + console.log(`ViewModel: No listeners found for property '${property}'`); + } + } + + // Publish event to event bus + publish(event, data) { + if (this._eventBus) { + this._eventBus.publish(event, data); + } + } + + // Get all data + getAll() { + return { ...this._data }; + } + + // Clear all data + clear() { + this._data = {}; + this._listeners.clear(); + } + + // UI State Management Methods + setUIState(key, value) { + this._uiState.set(key, value); + } + + getUIState(key) { + return this._uiState.get(key); + } + + getAllUIState() { + return new Map(this._uiState); + } + + clearUIState(key) { + if (key) { + this._uiState.delete(key); + } else { + this._uiState.clear(); + } + } + + // Check if a property has changed + hasChanged(property) { + return this._data[property] !== this._previousData[property]; + } + + // Get previous value of a property + getPrevious(property) { + return this._previousData[property]; + } + + // Batch update with change detection + batchUpdate(updates, options = {}) { + const { preserveUIState = true, notifyChanges = true } = options; + + if (preserveUIState) { + // Store current UI state + const currentUIState = new Map(this._uiState); + + // Apply updates + Object.keys(updates).forEach(key => { + this._data[key] = updates[key]; + }); + + // Restore UI state + this._uiState = currentUIState; + } else { + // Apply updates normally + Object.keys(updates).forEach(key => { + this._data[key] = updates[key]; + }); + } + + // Notify listeners if requested + if (notifyChanges) { + Object.keys(updates).forEach(key => { + this._notifyListeners(key, updates[key], this._previousData[key]); + }); + } + } +} + +// Base Component class with enhanced state preservation +class Component { + constructor(container, viewModel, eventBus) { + this.container = container; + this.viewModel = viewModel; + this.eventBus = eventBus; + this.isMounted = false; + this.unsubscribers = []; + this.uiState = new Map(); // Local UI state for this component + + // Set event bus on view model + if (this.viewModel) { + this.viewModel.setEventBus(eventBus); + } + + // Bind methods + this.render = this.render.bind(this); + this.mount = this.mount.bind(this); + this.unmount = this.unmount.bind(this); + this.updatePartial = this.updatePartial.bind(this); + } + + // Mount the component + mount() { + if (this.isMounted) return; + + console.log(`${this.constructor.name}: Starting mount...`); + this.isMounted = true; + this.setupEventListeners(); + this.setupViewModelListeners(); + this.render(); + + console.log(`${this.constructor.name}: Mounted successfully`); + } + + // Unmount the component + unmount() { + if (!this.isMounted) return; + + this.isMounted = false; + this.cleanupEventListeners(); + this.cleanupViewModelListeners(); + + console.log(`${this.constructor.name} unmounted`); + } + + // Pause the component (keep alive but pause activity) + pause() { + if (!this.isMounted) return; + + console.log(`${this.constructor.name}: Pausing component`); + + // Pause any active timers or animations + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + + // Pause any ongoing operations + this.isPaused = true; + + // Override in subclasses to pause specific functionality + this.onPause(); + } + + // Resume the component + resume() { + if (!this.isMounted || !this.isPaused) return; + + console.log(`${this.constructor.name}: Resuming component`); + + this.isPaused = false; + + // Restart any necessary timers or operations + this.onResume(); + + // Re-render if needed + if (this.shouldRenderOnResume()) { + this.render(); + } + } + + // Override in subclasses to handle pause-specific logic + onPause() { + // Default implementation does nothing + } + + // Override in subclasses to handle resume-specific logic + onResume() { + // Default implementation does nothing + } + + // Override in subclasses to determine if re-render is needed on resume + shouldRenderOnResume() { + // Default: don't re-render on resume + return false; + } + + // Setup event listeners (override in subclasses) + setupEventListeners() { + // Override in subclasses + } + + // Setup view model listeners (override in subclasses) + setupViewModelListeners() { + // Override in subclasses + } + + // Cleanup event listeners (override in subclasses) + cleanupEventListeners() { + // Override in subclasses + } + + // Cleanup view model listeners (override in subclasses) + cleanupViewModelListeners() { + // Override in subclasses + } + + // Render the component (override in subclasses) + render() { + // Override in subclasses + } + + // Partial update method for efficient data updates + updatePartial(property, newValue, previousValue) { + // Override in subclasses to implement partial updates + console.log(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue }); + } + + // UI State Management Methods + setUIState(key, value) { + this.uiState.set(key, value); + // Also store in view model for persistence across refreshes + if (this.viewModel) { + this.viewModel.setUIState(key, value); + } + } + + getUIState(key) { + // First try local state, then view model state + return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null); + } + + getAllUIState() { + const localState = new Map(this.uiState); + const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map(); + + // Merge states, with local state taking precedence + const mergedState = new Map(viewModelState); + localState.forEach((value, key) => mergedState.set(key, value)); + + return mergedState; + } + + clearUIState(key) { + if (key) { + this.uiState.delete(key); + if (this.viewModel) { + this.viewModel.clearUIState(key); + } + } else { + this.uiState.clear(); + if (this.viewModel) { + this.viewModel.clearUIState(); + } + } + } + + // Restore UI state from view model + restoreUIState() { + if (this.viewModel) { + const viewModelState = this.viewModel.getAllUIState(); + viewModelState.forEach((value, key) => { + this.uiState.set(key, value); + }); + } + } + + // Helper method to add event listener and track for cleanup + addEventListener(element, event, handler) { + element.addEventListener(event, handler); + this.unsubscribers.push(() => { + element.removeEventListener(event, handler); + }); + } + + // Helper method to subscribe to event bus and track for cleanup + subscribeToEvent(event, handler) { + const unsubscribe = this.eventBus.subscribe(event, handler); + this.unsubscribers.push(unsubscribe); + } + + // Helper method to subscribe to view model property and track for cleanup + subscribeToProperty(property, handler) { + if (this.viewModel) { + const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => { + // Call handler with both new and previous values for change detection + handler(newValue, previousValue); + }); + this.unsubscribers.push(unsubscribe); + } + } + + // Helper method to find element within component container + findElement(selector) { + return this.container.querySelector(selector); + } + + // Helper method to find all elements within component container + findAllElements(selector) { + return this.container.querySelectorAll(selector); + } + + // Helper method to set innerHTML safely + setHTML(selector, html) { + console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`); + + let element; + if (selector === '') { + // Empty selector means set HTML on the component's container itself + element = this.container; + console.log(`${this.constructor.name}: Using component container for empty selector`); + } else { + // Find element within the component's container + element = this.findElement(selector); + } + + if (element) { + console.log(`${this.constructor.name}: Element found, setting innerHTML`); + element.innerHTML = html; + console.log(`${this.constructor.name}: innerHTML set successfully`); + } else { + console.error(`${this.constructor.name}: Element not found for selector '${selector}'`); + } + } + + // Helper method to set text content safely + setText(selector, text) { + const element = this.findElement(selector); + if (element) { + element.textContent = text; + } + } + + // Helper method to add/remove CSS classes + setClass(selector, className, add = true) { + const element = this.findElement(selector); + if (element) { + if (add) { + element.classList.add(className); + } else { + element.classList.remove(className); + } + } + } + + // Helper method to set CSS styles + setStyle(selector, property, value) { + const element = this.findElement(selector); + if (element) { + element.style[property] = value; + } + } + + // Helper method to show/hide elements + setVisible(selector, visible) { + const element = this.findElement(selector); + if (element) { + element.style.display = visible ? '' : 'none'; + } + } + + // Helper method to enable/disable elements + setEnabled(selector, enabled) { + const element = this.findElement(selector); + if (element) { + element.disabled = !enabled; + } + } +} + +// Application class to manage components and routing +class App { + constructor() { + this.eventBus = new EventBus(); + this.components = new Map(); + this.currentView = null; + this.routes = new Map(); + this.navigationInProgress = false; + this.navigationQueue = []; + this.lastNavigationTime = 0; + this.navigationCooldown = 300; // 300ms cooldown between navigations + + // Component cache to keep components alive + this.componentCache = new Map(); + this.cachedViews = new Set(); + } + + // Register a route + registerRoute(name, componentClass, containerId, viewModel = null) { + this.routes.set(name, { componentClass, containerId, viewModel }); + + // Pre-initialize component in cache for better performance + this.preInitializeComponent(name, componentClass, containerId, viewModel); + } + + // Pre-initialize component in cache + preInitializeComponent(name, componentClass, containerId, viewModel) { + const container = document.getElementById(containerId); + if (!container) return; + + // Create component instance but don't mount it yet + const component = new componentClass(container, viewModel, this.eventBus); + component.routeName = name; + component.isCached = true; + + // Store in cache + this.componentCache.set(name, component); + console.log(`App: Pre-initialized component for route '${name}'`); + } + + // Navigate to a route + navigateTo(routeName) { + // Check cooldown period + const now = Date.now(); + if (now - this.lastNavigationTime < this.navigationCooldown) { + console.log(`App: Navigation cooldown active, skipping route '${routeName}'`); + return; + } + + // If navigation is already in progress, queue this request + if (this.navigationInProgress) { + console.log(`App: Navigation in progress, queuing route '${routeName}'`); + if (!this.navigationQueue.includes(routeName)) { + this.navigationQueue.push(routeName); + } + return; + } + + // If trying to navigate to the same route, do nothing + if (this.currentView && this.currentView.routeName === routeName) { + console.log(`App: Already on route '${routeName}', skipping navigation`); + return; + } + + this.lastNavigationTime = now; + this.performNavigation(routeName); + } + + // Perform the actual navigation + async performNavigation(routeName) { + this.navigationInProgress = true; + + try { + console.log(`App: Navigating to route '${routeName}'`); + const route = this.routes.get(routeName); + if (!route) { + console.error(`Route '${routeName}' not found`); + return; + } + + console.log(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`); + + // Get or create component from cache + let component = this.componentCache.get(routeName); + if (!component) { + console.log(`App: Component not in cache, creating new instance for '${routeName}'`); + const container = document.getElementById(route.containerId); + if (!container) { + console.error(`Container '${route.containerId}' not found`); + return; + } + + component = new route.componentClass(container, route.viewModel, this.eventBus); + component.routeName = routeName; + component.isCached = true; + this.componentCache.set(routeName, component); + } + + // Hide current view smoothly + if (this.currentView) { + console.log('App: Hiding current view'); + await this.hideCurrentView(); + } + + // Show new view + console.log(`App: Showing new view '${routeName}'`); + await this.showView(routeName, component); + + // Update navigation state + this.updateNavigation(routeName); + + // Set as current view + this.currentView = component; + + // Mark view as cached for future use + this.cachedViews.add(routeName); + + console.log(`App: Navigation to '${routeName}' completed`); + + } catch (error) { + console.error('App: Navigation failed:', error); + } finally { + this.navigationInProgress = false; + + // Process any queued navigation requests + if (this.navigationQueue.length > 0) { + const nextRoute = this.navigationQueue.shift(); + console.log(`App: Processing queued navigation to '${nextRoute}'`); + setTimeout(() => this.navigateTo(nextRoute), 100); + } + } + } + + // Hide current view smoothly + async hideCurrentView() { + if (!this.currentView) return; + + // If component is mounted, pause it instead of unmounting + if (this.currentView.isMounted) { + console.log('App: Pausing current view instead of unmounting'); + this.currentView.pause(); + } + + // Fade out the container + if (this.currentView.container) { + this.currentView.container.style.opacity = '0'; + this.currentView.container.style.transition = 'opacity 0.15s ease-out'; + } + + // Wait for fade out to complete + await new Promise(resolve => setTimeout(resolve, 150)); + } + + // Show view smoothly + async showView(routeName, component) { + const container = component.container; + + // Ensure component is mounted (but not necessarily active) + if (!component.isMounted) { + console.log(`App: Mounting component for '${routeName}'`); + component.mount(); + } else { + console.log(`App: Resuming component for '${routeName}'`); + component.resume(); + } + + // Fade in the container + container.style.opacity = '0'; + container.style.transition = 'opacity 0.2s ease-in'; + + // Small delay to ensure smooth transition + await new Promise(resolve => setTimeout(resolve, 50)); + + // Fade in + container.style.opacity = '1'; + } + + // Update navigation state + updateNavigation(activeRoute) { + // Remove active class from all nav tabs + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.classList.remove('active'); + }); + + // Add active class to current route tab + const activeTab = document.querySelector(`[data-view="${activeRoute}"]`); + if (activeTab) { + activeTab.classList.add('active'); + } + + // Hide all view contents with smooth transition + document.querySelectorAll('.view-content').forEach(view => { + view.classList.remove('active'); + view.style.opacity = '0'; + view.style.transition = 'opacity 0.15s ease-out'; + }); + + // Show current view content with smooth transition + const activeView = document.getElementById(`${activeRoute}-view`); + if (activeView) { + activeView.classList.add('active'); + // Small delay to ensure smooth transition + setTimeout(() => { + activeView.style.opacity = '1'; + activeView.style.transition = 'opacity 0.2s ease-in'; + }, 50); + } + } + + // Register a component + registerComponent(name, component) { + this.components.set(name, component); + } + + // Get a component by name + getComponent(name) { + return this.components.get(name); + } + + // Get the event bus + getEventBus() { + return this.eventBus; + } + + // Initialize the application + init() { + console.log('SPORE UI Framework initialized'); + + // Note: Navigation is now handled by the app initialization + // to ensure routes are registered before navigation + } + + // Setup navigation + setupNavigation() { + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', () => { + const routeName = tab.dataset.view; + this.navigateTo(routeName); + }); + }); + } + + // Clean up cached components (call when app is shutting down) + cleanup() { + console.log('App: Cleaning up cached components...'); + + this.componentCache.forEach((component, routeName) => { + if (component.isMounted) { + console.log(`App: Unmounting cached component '${routeName}'`); + component.unmount(); + } + }); + + this.componentCache.clear(); + this.cachedViews.clear(); + } +} + +// Global app instance +window.app = new App(); \ No newline at end of file diff --git a/public/index.html b/public/index.html index c71c866..254f33b 100644 --- a/public/index.html +++ b/public/index.html @@ -25,7 +25,7 @@
Primary Node: Discovering... -
- - - - - - -
-
- Free Heap: - ${Math.round(nodeStatus.freeHeap / 1024)}KB -
-
- Chip ID: - ${nodeStatus.chipId} -
-
- SDK Version: - ${nodeStatus.sdkVersion} -
-
- CPU Frequency: - ${nodeStatus.cpuFreqMHz}MHz -
-
- Flash Size: - ${Math.round(nodeStatus.flashChipSize / 1024)}KB -
-
- -
-

Available API Endpoints:

- ${nodeStatus.api ? nodeStatus.api.map(endpoint => - `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` - ).join('') : '
No API endpoints available
'} -
- -
-
Loading tasks...
-
- -
-
-

Firmware Update

-
- - -
Select a .bin or .hex file to upload
- -
-
-
- - `; - - // Set up tab switching - setupTabs(container); - - // Load tasks data for the tasks tab - loadTasksData(container, nodeStatus); - - console.log('Node details HTML set successfully'); -} - -// Function to set up tab switching -function setupTabs(container) { - const tabButtons = container.querySelectorAll('.tab-button'); - const tabContents = container.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', (e) => { - // Prevent the click event from bubbling up to the card - e.stopPropagation(); - - const targetTab = button.dataset.tab; - - // 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 clicked button and corresponding content - button.classList.add('active'); - const targetContent = container.querySelector(`#${targetTab}-tab`); - if (targetContent) { - targetContent.classList.add('active'); - } - }); - }); - - // Also prevent event propagation on tab content areas - tabContents.forEach(content => { - content.addEventListener('click', (e) => { - e.stopPropagation(); - }); - }); - - // Set up firmware upload button - const uploadBtn = container.querySelector('.upload-btn[data-action="select-file"]'); - if (uploadBtn) { - uploadBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const fileInput = container.querySelector('#firmware-file'); - if (fileInput) { - fileInput.click(); - } - }); - - // Set up file input change handler - const fileInput = container.querySelector('#firmware-file'); - if (fileInput) { - fileInput.addEventListener('change', async (e) => { - e.stopPropagation(); - const file = e.target.files[0]; - if (file) { - await uploadFirmware(file, container); - } - }); - } - } -} - -// Function to load tasks data -async function loadTasksData(container, nodeStatus) { - const tasksTab = container.querySelector('#tasks-tab'); - if (!tasksTab) return; - - try { - const response = await client.getTasksStatus(); - console.log('Tasks data received:', response); - - if (response && response.length > 0) { - const tasksHTML = response.map(task => ` -
-
- ${task.name || 'Unknown Task'} - - ${task.running ? '🟢 Running' : '🔴 Stopped'} - -
-
- Interval: ${task.interval}ms - ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'} -
-
- `).join(''); - - tasksTab.innerHTML = ` -

Active Tasks

- ${tasksHTML} - `; - } else { - tasksTab.innerHTML = ` -
-
📋 No active tasks found
-
- This node has no running tasks -
-
- `; - } - } catch (error) { - console.error('Failed to load tasks:', error); - tasksTab.innerHTML = ` -
- Error loading tasks:
- ${error.message} -
- `; - } -} - -// Function to upload firmware -async function uploadFirmware(file, container) { - const uploadStatus = container.querySelector('#upload-status'); - const uploadBtn = container.querySelector('.upload-btn'); - const originalText = uploadBtn.textContent; - - try { - // Show upload status - uploadStatus.style.display = 'block'; - uploadStatus.innerHTML = ` -
-
📤 Uploading ${file.name}...
-
Size: ${(file.size / 1024).toFixed(1)}KB
-
- `; - - // Disable upload button - uploadBtn.disabled = true; - uploadBtn.textContent = '⏳ Uploading...'; - - // Get the member IP from the card - const memberCard = container.closest('.member-card'); - const memberIp = memberCard.dataset.memberIp; - - if (!memberIp) { - throw new Error('Could not determine target node IP address'); - } - - // Create FormData for multipart upload - const formData = new FormData(); - formData.append('file', file); - - // Upload to backend - const response = await fetch('/api/node/update?ip=' + encodeURIComponent(memberIp), { - method: 'POST', - body: formData - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - - // Show success - uploadStatus.innerHTML = ` -
-
✅ Firmware uploaded successfully!
-
Node: ${memberIp}
-
Size: ${(file.size / 1024).toFixed(1)}KB
-
- `; - - console.log('Firmware upload successful:', result); - - } catch (error) { - console.error('Firmware upload failed:', error); - - // Show error - uploadStatus.innerHTML = ` -
-
❌ Upload failed: ${error.message}
-
- `; - } finally { - // Re-enable upload button - uploadBtn.disabled = false; - uploadBtn.textContent = originalText; - - // Clear file input - const fileInput = container.querySelector('#firmware-file'); - if (fileInput) { - fileInput.value = ''; - } - } -} - -// Function to display cluster members -function displayClusterMembers(members, expandedCards = new Map()) { - const container = document.getElementById('cluster-members-container'); - - if (!members || members.length === 0) { - container.innerHTML = ` -
-
🌐
-
No cluster members found
-
- The cluster might be empty or not yet discovered -
-
- `; - return; - } - - 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' ? '🟢' : '🔴'; - - return ` -
-
-
-
${member.hostname || 'Unknown Device'}
-
${member.ip || 'No IP'}
-
- ${statusIcon} ${statusText} -
-
- Latency: - ${member.latency ? member.latency + 'ms' : 'N/A'} -
-
-
- - - -
-
-
-
Loading detailed information...
-
-
- `; - }).join(''); - - container.innerHTML = membersHTML; - - // Add event listeners for expand/collapse - console.log('Setting up event listeners for', members.length, 'member cards'); - - // Small delay to ensure DOM is ready - setTimeout(() => { - document.querySelectorAll('.member-card').forEach((card, index) => { - const expandIcon = card.querySelector('.expand-icon'); - const memberDetails = card.querySelector('.member-details'); - const memberIp = card.dataset.memberIp; - - console.log(`Setting up card ${index} with IP: ${memberIp}`); - - // Restore expanded state if this card was expanded before refresh - if (expandedCards.has(memberIp)) { - console.log(`Restoring expanded state for ${memberIp}`); - const restoredContent = expandedCards.get(memberIp); - console.log(`Restored content length: ${restoredContent.length} characters`); - memberDetails.innerHTML = restoredContent; - card.classList.add('expanded'); - expandIcon.classList.add('expanded'); - - // Re-setup tabs for restored content - setupTabs(memberDetails); - console.log(`Successfully restored expanded state for ${memberIp}`); - } else { - console.log(`No expanded state to restore for ${memberIp}`); - } - - // Make the entire card clickable - card.addEventListener('click', async (e) => { - // Don't trigger if clicking on the expand icon (to avoid double-triggering) - if (e.target === expandIcon) { - return; - } - - console.log('Card clicked for IP:', memberIp); - - const isExpanding = !card.classList.contains('expanded'); - console.log('Is expanding:', isExpanding); - - if (isExpanding) { - // Expanding - fetch detailed status - console.log('Starting to expand...'); - await loadNodeDetails(card, memberIp); - card.classList.add('expanded'); - expandIcon.classList.add('expanded'); - console.log('Card expanded successfully'); - } else { - // Collapsing - console.log('Collapsing...'); - card.classList.remove('expanded'); - expandIcon.classList.remove('expanded'); - console.log('Card collapsed successfully'); - } - }); - - // Keep the expand icon click handler for visual feedback - if (expandIcon) { - expandIcon.addEventListener('click', async (e) => { - e.stopPropagation(); - console.log('Expand icon clicked for IP:', memberIp); - - const isExpanding = !card.classList.contains('expanded'); - console.log('Is expanding:', isExpanding); - - if (isExpanding) { - // Expanding - fetch detailed status - console.log('Starting to expand...'); - await loadNodeDetails(card, memberIp); - card.classList.add('expanded'); - expandIcon.classList.add('expanded'); - console.log('Card expanded successfully'); - } else { - // Collapsing - console.log('Collapsing...'); - card.classList.remove('expanded'); - expandIcon.classList.remove('expanded'); - console.log('Card collapsed successfully'); - } - }); - - console.log(`Event listener added for expand icon on card ${index}`); - } else { - console.error(`No expand icon found for card ${index}`); - } - - console.log(`Event listener added for card ${index}`); - }); - }, 100); -} - -// Load cluster members when page loads -document.addEventListener('DOMContentLoaded', function() { - refreshClusterMembers(); - updatePrimaryNodeDisplay(); // Also update primary node display - setupNavigation(); - setupFirmwareView(); - - // Set up periodic primary node updates (every 10 seconds) - setInterval(updatePrimaryNodeDisplay, 10000); -}); - -// Auto-refresh every 30 seconds -// FIXME not working properly: scroll position is not preserved, if there is an upload happening, this mus also be handled -//setInterval(refreshClusterMembers, 30000); - -// Setup navigation menu -function setupNavigation() { - const navTabs = document.querySelectorAll('.nav-tab'); - const viewContents = document.querySelectorAll('.view-content'); - - navTabs.forEach(tab => { - tab.addEventListener('click', () => { - const targetView = tab.dataset.view; - - // Update active tab - navTabs.forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - - // Update active view - viewContents.forEach(view => view.classList.remove('active')); - const targetViewElement = document.getElementById(`${targetView}-view`); - if (targetViewElement) { - targetViewElement.classList.add('active'); - } - - // Refresh the active view - if (targetView === 'cluster') { - refreshClusterMembers(); - } else if (targetView === 'firmware') { - refreshFirmwareView(); - } - }); - }); -} - -// Setup firmware view -function setupFirmwareView() { - // Setup global firmware file input - const globalFirmwareFile = document.getElementById('global-firmware-file'); - if (globalFirmwareFile) { - globalFirmwareFile.addEventListener('change', handleGlobalFirmwareFileSelect); - } - - // Setup target selection - const targetRadios = document.querySelectorAll('input[name="target-type"]'); - const specificNodeSelect = document.getElementById('specific-node-select'); - - targetRadios.forEach(radio => { - radio.addEventListener('change', () => { - if (radio.value === 'specific') { - specificNodeSelect.style.visibility = 'visible'; - specificNodeSelect.style.opacity = '1'; - populateNodeSelect(); - } else { - specificNodeSelect.style.visibility = 'hidden'; - specificNodeSelect.style.opacity = '0'; - } - updateDeployButton(); - }); - }); - - // Setup specific node select change handler - if (specificNodeSelect) { - specificNodeSelect.addEventListener('change', updateDeployButton); - } - - // Setup deploy button - const deployBtn = document.getElementById('deploy-btn'); - if (deployBtn) { - deployBtn.addEventListener('click', handleDeployFirmware); - } - - // Initial button state - updateDeployButton(); -} - -// Handle file selection for the compact interface -function handleGlobalFirmwareFileSelect(event) { - const file = event.target.files[0]; - const fileInfo = document.getElementById('file-info'); - const deployBtn = document.getElementById('deploy-btn'); - - if (file) { - fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`; - fileInfo.classList.add('has-file'); - deployBtn.disabled = false; - } else { - fileInfo.textContent = 'No file selected'; - fileInfo.classList.remove('has-file'); - deployBtn.disabled = true; - } -} - -// Update deploy button state -function updateDeployButton() { - const deployBtn = document.getElementById('deploy-btn'); - const fileInput = document.getElementById('global-firmware-file'); - const targetType = document.querySelector('input[name="target-type"]:checked'); - const specificNodeSelect = document.getElementById('specific-node-select'); - - if (!deployBtn || !fileInput) return; - - const hasFile = fileInput.files && fileInput.files.length > 0; - const isValidTarget = targetType.value === 'all' || - (targetType.value === 'specific' && specificNodeSelect.value); - - deployBtn.disabled = !hasFile || !isValidTarget; -} - -// Handle deploy firmware button click -async function handleDeployFirmware() { - const fileInput = document.getElementById('global-firmware-file'); - const targetType = document.querySelector('input[name="target-type"]:checked').value; - const specificNode = document.getElementById('specific-node-select').value; - - if (!fileInput.files || !fileInput.files[0]) { - alert('Please select a firmware file first.'); - return; - } - - const file = fileInput.files[0]; - - if (targetType === 'specific' && !specificNode) { - alert('Please select a specific node to update.'); - return; - } - - try { - // Disable deploy button during upload - const deployBtn = document.getElementById('deploy-btn'); - deployBtn.disabled = true; - deployBtn.classList.add('loading'); - deployBtn.textContent = '⏳ Deploying...'; - - if (targetType === 'all') { - await uploadFirmwareToAllNodes(file); - } else { - await uploadFirmwareToSpecificNode(file, specificNode); - } - - // Reset interface after successful upload - fileInput.value = ''; - document.getElementById('file-info').textContent = 'No file selected'; - document.getElementById('file-info').classList.remove('has-file'); - - } catch (error) { - console.error('Firmware deployment failed:', error); - alert(`Deployment failed: ${error.message}`); - } finally { - // Re-enable deploy button - const deployBtn = document.getElementById('deploy-btn'); - deployBtn.disabled = false; - deployBtn.classList.remove('loading'); - deployBtn.textContent = '🚀 Deploy Firmware'; - updateDeployButton(); - } -} - -// Upload firmware to all nodes -async function uploadFirmwareToAllNodes(file) { - try { - // Get current cluster members - const response = await client.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 - showFirmwareUploadProgress(file, nodes); - - // Start batch upload - const results = await performBatchFirmwareUpload(file, nodes); - - // Display results - displayFirmwareUploadResults(results); - - } catch (error) { - console.error('Failed to upload firmware to all nodes:', error); - alert(`Upload failed: ${error.message}`); - } -} - -// Upload firmware to specific node -async function uploadFirmwareToSpecificNode(file, nodeIp) { - try { - const confirmed = confirm(`Upload firmware to node ${nodeIp}?`); - if (!confirmed) return; - - // Show upload progress area - showFirmwareUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]); - - // Perform single node upload with progress tracking - const result = await performSingleFirmwareUploadWithProgress(file, nodeIp); - - // Display results - displayFirmwareUploadResults([result]); - - } catch (error) { - console.error(`Failed to upload firmware to node ${nodeIp}:`, error); - alert(`Upload failed: ${error.message}`); - } -} - -// Perform batch firmware upload to multiple nodes -async function performBatchFirmwareUpload(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 - show current node being processed - updateFirmwareUploadProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); - - // Upload to this node - const result = await performSingleFirmwareUpload(file, nodeIp); - results.push(result); - successfulUploads++; - - // Update progress - show completion and update progress bar with actual success rate - updateFirmwareUploadProgress(i + 1, totalNodes, nodeIp, 'Completed'); - updateMultiNodeProgress(successfulUploads, totalNodes); - - } catch (error) { - console.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 - show failure and update progress bar with actual success rate - updateFirmwareUploadProgress(i + 1, totalNodes, nodeIp, 'Failed'); - updateMultiNodeProgress(successfulUploads, totalNodes); - } - - // Small delay between uploads to avoid overwhelming the network - if (i < nodes.length - 1) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - // Update final progress based on successful uploads - updateFinalProgress(successfulUploads, totalNodes); - - return results; -} - -// Perform single firmware upload to a specific node -async function performSingleFirmwareUpload(file, nodeIp) { - try { - // Create FormData for the upload - const formData = new FormData(); - formData.append('file', file); - - // Upload to backend - const response = await fetch(`/api/node/update?ip=${encodeURIComponent(nodeIp)}`, { - method: 'POST', - body: formData - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - - return { - nodeIp: nodeIp, - hostname: nodeIp, // Will be updated if we have more info - success: true, - result: result, - timestamp: new Date().toISOString() - }; - - } catch (error) { - throw new Error(`Upload to ${nodeIp} failed: ${error.message}`); - } -} - -// Perform single firmware upload to a specific node with progress tracking -async function performSingleFirmwareUploadWithProgress(file, nodeIp) { - try { - // Simulate upload progress for single node - await simulateUploadProgress(nodeIp); - - // Create FormData for the upload - const formData = new FormData(); - formData.append('file', file); - - // Upload to backend - const response = await fetch(`/api/node/update?ip=${encodeURIComponent(nodeIp)}`, { - method: 'POST', - body: formData - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - - return { - nodeIp: nodeIp, - hostname: nodeIp, // Will be updated if we have more info - success: true, - result: result, - timestamp: new Date().toISOString() - }; - - } catch (error) { - throw new Error(`Upload to ${nodeIp} failed: ${error.message}`); - } -} - -// Simulate upload progress for single node uploads -async function simulateUploadProgress(nodeIp) { - const progressSteps = [10, 25, 50, 75, 90, 100]; - const totalSteps = progressSteps.length; - - for (let i = 0; i < totalSteps; i++) { - const progress = progressSteps[i]; - updateSingleNodeProgress(progress, nodeIp); - - // Wait a bit between progress updates (simulating actual upload time) - if (i < totalSteps - 1) { - await new Promise(resolve => setTimeout(resolve, 300)); - } - } -} - -// Update progress for single node uploads -function updateSingleNodeProgress(percentage, nodeIp) { - const progressBar = document.getElementById('overall-progress-bar'); - const progressText = document.querySelector('.progress-text'); - - if (progressBar && progressText) { - progressBar.style.width = `${percentage}%`; - progressText.textContent = `${percentage}% Complete`; - - // Update progress bar color based on completion - if (percentage === 100) { - progressBar.style.backgroundColor = '#4ade80'; - } else if (percentage > 50) { - progressBar.style.backgroundColor = '#60a5fa'; - } else { - progressBar.style.backgroundColor = '#fbbf24'; - } - } -} - -// Update final progress based on successful vs total uploads -function updateFinalProgress(successfulUploads, totalNodes) { - const progressBar = document.getElementById('overall-progress-bar'); - const progressText = document.querySelector('.progress-text'); - const progressHeader = document.querySelector('.progress-header h3'); - - if (progressBar && progressText) { - const successPercentage = Math.round((successfulUploads / totalNodes) * 100); - progressBar.style.width = `${successPercentage}%`; - - if (successfulUploads === totalNodes) { - progressText.textContent = '100% Complete'; - progressBar.style.backgroundColor = '#4ade80'; - } else { - progressText.textContent = `${successfulUploads}/${totalNodes} Successful`; - progressBar.style.backgroundColor = '#f87171'; - } - } - - if (progressHeader) { - progressHeader.textContent = `📤 Firmware Upload Results (${successfulUploads}/${totalNodes} Successful)`; - } -} - -// Show firmware upload progress area -function showFirmwareUploadProgress(file, nodes) { - const container = document.getElementById('firmware-nodes-list'); - - const progressHTML = ` -
-
-

📤 Firmware Upload Progress

-
- File: ${file.name} - Size: ${(file.size / 1024).toFixed(1)}KB - Targets: ${nodes.length} node(s) -
-
-
-
-
- 0/0 Successful (0%) -
-
- Status: Preparing upload... -
-
-
- ${nodes.map(node => ` -
-
- ${node.hostname || node.ip} - ${node.ip} -
-
Pending...
-
-
- `).join('')} -
-
- `; - - container.innerHTML = progressHTML; -} - -// Update firmware upload progress -function updateFirmwareUploadProgress(current, total, nodeIp, status) { - const progressItem = document.querySelector(`[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(); - } - } - } - } - - // Update progress header to show current node being processed - const progressHeader = document.querySelector('.progress-header h3'); - if (progressHeader) { - progressHeader.textContent = `📤 Firmware Upload Progress (${current}/${total})`; - } - - // Update progress summary - const progressSummary = document.getElementById('progress-summary'); - if (progressSummary) { - if (status === 'Uploading...') { - progressSummary.innerHTML = `Status: Uploading to ${nodeIp} (${current}/${total})`; - } else if (status === 'Completed') { - // For multi-node uploads, show success rate - if (total > 1) { - const successfulNodes = document.querySelectorAll('.progress-status.success').length; - const totalNodes = total; - const successRate = Math.round((successfulNodes / totalNodes) * 100); - progressSummary.innerHTML = `Status: Completed upload to ${nodeIp}. Overall: ${successfulNodes}/${totalNodes} successful (${successRate}%)`; - } else { - progressSummary.innerHTML = `Status: Completed upload to ${nodeIp} (${current}/${total})`; - } - } else if (status === 'Failed') { - // For multi-node uploads, show success rate - if (total > 1) { - const successfulNodes = document.querySelectorAll('.progress-status.success').length; - const totalNodes = total; - const successRate = Math.round((successfulNodes / totalNodes) * 100); - progressSummary.innerHTML = `Status: Failed upload to ${nodeIp}. Overall: ${successfulNodes}/${totalNodes} successful (${successRate}%)`; - } else { - progressSummary.innerHTML = `Status: Failed upload to ${nodeIp} (${current}/${total})`; - } - } - } - - // IMPORTANT: Do NOT update the progress bar here - let updateMultiNodeProgress handle it - // The progress bar should only reflect actual successful uploads, not nodes processed -} - -// Update progress for multi-node uploads based on actual success rate -function updateMultiNodeProgress(successfulUploads, totalNodes) { - const progressBar = document.getElementById('overall-progress-bar'); - const progressText = document.querySelector('.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'; - } - } -} - -// Display firmware upload results -function displayFirmwareUploadResults(results) { - // No need to display separate results widget - the progress area already shows all the information - // Just update the progress area to show final status - const progressHeader = document.querySelector('.progress-header h3'); - const progressSummary = document.getElementById('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 (successCount === totalCount) { - progressHeader.textContent = `📤 Firmware Upload Complete (${successCount}/${totalCount} Successful)`; - progressSummary.innerHTML = `✅ All uploads completed successfully at ${new Date().toLocaleTimeString()}`; - } else { - progressHeader.textContent = `📤 Firmware Upload Results (${successCount}/${totalCount} Successful)`; - progressSummary.innerHTML = `⚠️ Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`; - } - } -} - -// Clear firmware upload results -function clearFirmwareResults() { - const container = document.getElementById('firmware-nodes-list'); - container.innerHTML = ''; -} - -// Add refresh button to progress area -function addRefreshButtonToProgress() { - const progressHeader = document.querySelector('.progress-header'); - if (progressHeader && !progressHeader.querySelector('.progress-refresh-btn')) { - const refreshBtn = document.createElement('button'); - refreshBtn.className = 'progress-refresh-btn'; - refreshBtn.innerHTML = ` - - - - - `; - refreshBtn.title = 'Refresh firmware view'; - refreshBtn.onclick = refreshFirmwareView; - - // Add the refresh button to the header - progressHeader.appendChild(refreshBtn); - } -} - -// Populate node select dropdown -function populateNodeSelect() { - const select = document.getElementById('specific-node-select'); - if (!select) return; - - // Clear existing options - select.innerHTML = ''; - - // Get current cluster members and populate - const container = document.getElementById('cluster-members-container'); - const memberCards = container.querySelectorAll('.member-card'); - - memberCards.forEach(card => { - const memberIp = card.dataset.memberIp; - const hostname = card.querySelector('.member-name')?.textContent || memberIp; - - const option = document.createElement('option'); - option.value = memberIp; - option.textContent = `${hostname} (${memberIp})`; - select.appendChild(option); - }); -} - -// Refresh firmware view -function refreshFirmwareView() { - populateNodeSelect(); - addRefreshButtonToProgress(); // Add refresh button after populating nodes -} \ No newline at end of file diff --git a/public/simple-test.html b/public/simple-test.html new file mode 100644 index 0000000..291e4f0 --- /dev/null +++ b/public/simple-test.html @@ -0,0 +1,104 @@ + + + + + + Simple Framework Test + + + +

Simple Framework Test

+ +
+

API Test

+ +
+
+ +
+

Framework Test

+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/public/styles.css b/public/styles.css index 47e4fad..6ef03f6 100644 --- a/public/styles.css +++ b/public/styles.css @@ -183,6 +183,25 @@ p { transform: rotate(180deg); } +.refresh-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; +} + +.refresh-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .members-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); @@ -194,10 +213,12 @@ p { border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 10px; padding: 1rem; - transition: all 0.3s ease; + transition: all 0.2s ease; cursor: pointer; position: relative; margin-bottom: 0.5rem; + opacity: 1; + transform: translateY(0); } .member-card::before { @@ -210,7 +231,7 @@ p { background: rgba(255, 255, 255, 0.08); border-radius: 12px; opacity: 0; - transition: opacity 0.3s ease; + transition: opacity 0.2s ease; pointer-events: none; } @@ -220,6 +241,7 @@ p { .member-card:hover { box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); } .member-card.expanded { @@ -229,7 +251,7 @@ p { } .member-card.expanded:hover { - transform: scale(1.02); + transform: scale(1.02) translateY(-2px); } .expand-icon:hover { @@ -285,20 +307,15 @@ p { } .member-details { - display: none; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out; opacity: 0; - margin-top: 0; - padding-top: 0; - border-top: 1px solid transparent; - transition: opacity 0.3s ease; } .member-card.expanded .member-details { - display: block; + max-height: 2000px; /* Allow full expansion for active tasks while maintaining smooth transition */ opacity: 1; - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid rgba(255, 255, 255, 0.2); } .detail-row { @@ -329,13 +346,20 @@ p { } .endpoint-item { - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.2); padding: 0.5rem; border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.8rem; font-family: 'Courier New', monospace; - border: 1px solid rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; +} + +.endpoint-item:hover { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .loading-details { @@ -521,20 +545,33 @@ p { } .upload-btn { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #ecf0f1; - padding: 0.75rem 1.5rem; - border-radius: 8px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.9); + padding: 0.75rem 1.25rem; + border-radius: 12px; cursor: pointer; - font-size: 1rem; - transition: all 0.3s ease; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + gap: 0.5rem; + backdrop-filter: blur(10px); margin-bottom: 1rem; + margin: auto; + } .upload-btn:hover { - background: rgba(255, 255, 255, 0.2); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); + border-color: rgba(255, 255, 255, 0.25); transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.upload-btn:active { + transform: translateY(0); } .upload-info { @@ -700,25 +737,65 @@ p { font-weight: 600; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 255, 0, 0.1); + transition: all 0.3s ease; +} + +/* Cluster Status States */ +.cluster-status-online { + background: linear-gradient(135deg, rgba(0, 255, 0, 0.15) 0%, rgba(0, 255, 0, 0.08) 100%); + border: 1px solid rgba(0, 255, 0, 0.25); + color: #00ff88; + box-shadow: 0 2px 8px rgba(0, 255, 0, 0.1); +} + +.cluster-status-offline { + background: linear-gradient(135deg, rgba(255, 0, 0, 0.15) 0%, rgba(255, 0, 0, 0.08) 100%); + border: 1px solid rgba(255, 0, 0, 0.25); + color: #ff6b6b; + box-shadow: 0 2px 8px rgba(255, 0, 0, 0.1); +} + +.cluster-status-connecting { + background: linear-gradient(135deg, rgba(255, 193, 7, 0.15) 0%, rgba(255, 193, 7, 0.08) 100%); + border: 1px solid rgba(255, 193, 7, 0.25); + color: #ffd54f; + box-shadow: 0 2px 8px rgba(255, 193, 7, 0.1); +} + +.cluster-status-discovering { + background: linear-gradient(135deg, rgba(33, 150, 243, 0.15) 0%, rgba(33, 150, 243, 0.08) 100%); + border: 1px solid rgba(33, 150, 243, 0.25); + color: #64b5f6; + box-shadow: 0 2px 8px rgba(33, 150, 243, 0.1); +} + +.cluster-status-error { + 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.25); + color: #ff8a80; + box-shadow: 0 2px 8px rgba(244, 67, 54, 0.1); } /* View Content Styles */ .view-content { display: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; } .view-content.active { display: block; + opacity: 1; } /* Firmware Section Styles */ .firmware-section { - background: rgba(0, 0, 0, 0.25); + background: rgba(0, 0, 0, 0.3); border-radius: 16px; - backdrop-filter: blur(15px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.08); - padding: 0.5rem; + backdrop-filter: blur(10px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.75rem; margin-bottom: 1rem; position: relative; overflow: hidden; @@ -746,11 +823,18 @@ p { .action-group { background: rgba(0, 0, 0, 0.2); - border-radius: 12px; - padding: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + padding: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); position: relative; overflow: hidden; + transition: all 0.2s ease; +} + +.action-group:hover { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); } .action-group h3 { @@ -772,16 +856,38 @@ p { display: flex; gap: 1rem; align-items: center; - padding: 0.75rem; - background: rgba(255, 255, 255, 0.03); + padding: 1rem; + background: rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - transition: all 0.3s ease; + border-radius: 10px; + transition: all 0.2s ease; + position: relative; + cursor: pointer; +} + +.compact-upload-row::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.08); + border-radius: 12px; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.compact-upload-row:hover::before { + opacity: 1; } .compact-upload-row:hover { - background: rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.25); border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); } .file-upload-area { @@ -802,39 +908,54 @@ p { } .upload-btn-compact { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #ffffff; - padding: 0.4rem 0.8rem; - border-radius: 6px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.9); + padding: 0.75rem 1.25rem; + border-radius: 12px; cursor: pointer; - font-size: 0.85rem; + font-size: 0.9rem; font-weight: 500; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + gap: 0.5rem; + backdrop-filter: blur(10px); white-space: nowrap; } .upload-btn-compact:hover { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.12) 100%); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); + border-color: rgba(255, 255, 255, 0.25); transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); } +.upload-btn-compact:active { + transform: translateY(0); +} + .file-info { color: rgba(255, 255, 255, 0.7); - font-size: 0.8rem; + font-size: 0.9rem; font-style: italic; - transition: all 0.3s ease; - padding: 0.25rem 0.5rem; - background: rgba(255, 255, 255, 0.05); - border-radius: 4px; - border: 1px solid transparent; + transition: all 0.2s ease; + padding: 0.75rem 1.25rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } +.file-info:hover { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + .file-info.has-file { color: #4ade80; background: rgba(74, 222, 128, 0.1); @@ -843,6 +964,12 @@ p { font-style: normal; } +.file-info.has-file:hover { + background: rgba(74, 222, 128, 0.15); + border-color: rgba(74, 222, 128, 0.3); + box-shadow: 0 2px 8px rgba(74, 222, 128, 0.2); +} + .target-options { display: flex; gap: 0.75rem; @@ -856,24 +983,17 @@ p { gap: 0.3rem; cursor: pointer; padding: 0.3rem; - border-radius: 4px; + border-radius: 6px; transition: all 0.2s ease; -} - -.specific-node-option { - gap: 0.5rem; - align-items: center; -} - -.specific-node-option .node-select { - margin-left: 0.5rem; - visibility: hidden; - opacity: 0; - transition: opacity 0.2s ease; + background: transparent; + border: none; } .target-option:hover { - background: rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.25); + border-color: transparent; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + transform: translateY(-1px); } .target-option input[type="radio"] { @@ -883,17 +1003,18 @@ p { .radio-custom { width: 14px; height: 14px; - border: 2px solid rgba(255, 255, 255, 0.3); + border: 2px solid rgba(255, 255, 255, 0.1); border-radius: 50%; position: relative; transition: all 0.2s ease; - background: rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.2); } .target-option input[type="radio"]:checked + .radio-custom { border-color: #667eea; background: #667eea; box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); + transform: scale(1.05); } .target-option input[type="radio"]:checked + .radio-custom::after { @@ -922,13 +1043,13 @@ p { .target-label { color: rgba(255, 255, 255, 0.9); - font-size: 0.85rem; + font-size: 0.9rem; font-weight: 500; } .node-select { - background: linear-gradient(135deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.7) 100%); - border: 1px solid rgba(255, 255, 255, 0.3); + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); color: #ffffff; padding: 0.3rem 0.5rem; border-radius: 6px; @@ -941,15 +1062,17 @@ p { } .node-select:hover { - border-color: rgba(255, 255, 255, 0.4); - background: linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.8) 100%); + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + transform: translateY(-1px); } .node-select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); - background: linear-gradient(135deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.9) 100%); + background: rgba(0, 0, 0, 0.3); } /* Style the dropdown options */ @@ -981,16 +1104,40 @@ p { color: #ffffff; } -.deploy-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border: none; - color: #ffffff; - padding: 0.5rem 1rem; +/* Style for no-nodes message */ +.no-nodes-message { + color: #fbbf24 !important; + font-size: 0.8rem !important; + margin-top: 0.25rem !important; + font-style: italic !important; + text-align: center; + padding: 0.5rem 0.75rem; border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(251, 191, 36, 0.3); + transition: all 0.2s ease; +} + +.no-nodes-message:hover { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(251, 191, 36, 0.4); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.deploy-btn { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.9); + padding: 0.75rem 1.25rem; + border-radius: 12px; cursor: pointer; font-size: 0.9rem; - font-weight: 600; + font-weight: 500; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + gap: 0.5rem; + backdrop-filter: blur(10px); min-width: 100px; position: relative; overflow: hidden; @@ -1013,8 +1160,14 @@ p { } .deploy-btn:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); + border-color: rgba(255, 255, 255, 0.25); transform: translateY(-2px); - box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.deploy-btn:active:not(:disabled) { + transform: translateY(0); } .deploy-btn:disabled { @@ -1178,13 +1331,21 @@ p { /* Firmware upload progress and results styling */ .firmware-upload-progress, .firmware-upload-results { - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.2); border-radius: 16px; backdrop-filter: blur(10px); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); border: 1px solid rgba(255, 255, 255, 0.1); padding: 1.5rem; margin-top: 1rem; + transition: all 0.2s ease; +} + +.firmware-upload-progress:hover, +.firmware-upload-results:hover { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } .progress-header, @@ -1213,20 +1374,21 @@ p { background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); border: 1px solid rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.9); - padding: 0.5rem; - border-radius: 8px; + padding: 0.75rem 1.25rem; + border-radius: 12px; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(10px); } .progress-refresh-btn:hover { background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); border-color: rgba(255, 255, 255, 0.25); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); } .progress-refresh-btn:active { @@ -1245,9 +1407,17 @@ p { .progress-info span, .results-summary span { padding: 0.25rem 0.5rem; - background: rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.2); border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; +} + +.progress-info span:hover, +.results-summary span:hover { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .overall-progress { @@ -1284,9 +1454,16 @@ p { .progress-summary { margin-top: 0.75rem; padding: 0.5rem 0.75rem; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; +} + +.progress-summary:hover { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); } .progress-summary span { @@ -1323,16 +1500,40 @@ p { justify-content: space-between; align-items: center; padding: 1rem; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.1); - transition: all 0.3s ease; + transition: all 0.2s ease; + position: relative; + cursor: pointer; +} + +.progress-item::before, +.result-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.08); + border-radius: 12px; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.progress-item:hover::before, +.result-item:hover::before { + opacity: 1; } .progress-item:hover, .result-item:hover { - background: rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.25); border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); } .progress-node-info, @@ -1461,4 +1662,67 @@ p { flex-direction: column; } } + +/* Loading and state transitions */ +.loading, .error, .empty-state { + opacity: 0; + animation: fadeIn 0.3s ease-in-out forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Smooth expand/collapse animations */ +.member-details { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out; + opacity: 0; +} + +.member-card.expanded .member-details { + max-height: 2000px; /* Allow full expansion for active tasks while maintaining smooth transition */ + opacity: 1; +} + +/* Expand icon rotation */ +.expand-icon svg { + transition: transform 0.2s ease-in-out; +} + +.member-card.expanded .expand-icon svg { + transform: rotate(180deg); +} + +/* Navigation tab transitions */ +.nav-tab { + transition: all 0.2s ease; +} + +.nav-tab:hover { + transform: translateY(-1px); +} + +.nav-tab.active { + transform: translateY(0); +} + +.specific-node-option { + gap: 0.5rem; + align-items: center; +} + +.specific-node-option .node-select { + margin-left: 0.5rem; + visibility: hidden; + opacity: 0; + transition: opacity 0.2s ease; } \ No newline at end of file diff --git a/public/test-caching-system.html b/public/test-caching-system.html new file mode 100644 index 0000000..05fb513 --- /dev/null +++ b/public/test-caching-system.html @@ -0,0 +1,351 @@ + + + + + + SPORE UI - Component Caching Test + + + + +
+
+

🧪 Component Caching System Test

+

This page tests the new component caching system to verify that components are not re-rendered on view switches.

+

Note: Components now start with clean default state (collapsed cards, status tab) and don't restore previous UI state.

+
+ + + + +
+
+ + + +
+
+
+
+
+ Primary Node: + Discovering... + +
+
+ +
+ +
+
+
Loading cluster members...
+
+
+
+
+ +
+
+
+
+
+

🚀 Firmware Update

+
+
+
+
+ + +
+ +
+ + + No file selected +
+
+ + +
+
+
+
+
+ +
+ +
+
+
+ +
+

Test Results:

+
Run a test to see results...
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/test-deploy-button.html b/public/test-deploy-button.html new file mode 100644 index 0000000..f3abb19 --- /dev/null +++ b/public/test-deploy-button.html @@ -0,0 +1,351 @@ + + + + + + Deploy Button Test + + + +

🚀 Deploy Button Test

+ +
+

Test Scenario: Deploy Button State

+

This test demonstrates the deploy button behavior when:

+ +
+ +
+

🚀 Firmware Update

+ +
+ + +
+ +
+ + + No file selected +
+ + +
+ +
+

Cluster Members

+
+
Loading cluster members...
+
+ + +
+ +
+

Test Instructions

+
    +
  1. Select "Specific Node" radio button - notice the deploy button remains disabled
  2. +
  3. Click "Add Test Node" to simulate cluster discovery
  4. +
  5. Select "Specific Node" again - now you should see nodes in the dropdown
  6. +
  7. Select a file - deploy button should remain disabled until you select a node
  8. +
  9. Select a specific node - deploy button should now be enabled
  10. +
  11. Click "Remove All Nodes" to test the "no nodes available" state
  12. +
+
+ + + + \ No newline at end of file diff --git a/public/test-framework.html b/public/test-framework.html new file mode 100644 index 0000000..8c7115a --- /dev/null +++ b/public/test-framework.html @@ -0,0 +1,131 @@ + + + + + + Framework Test + + + +

SPORE UI Framework Test

+ +
+

Framework Initialization Test

+
Checking...
+
+ +
+

Event Bus Test

+ +
+
+ +
+

View Model Test

+ + +
Name: (not set)
+
+ +
+

Component Test

+
+
Loading...
+
+ +
+ + + + + \ No newline at end of file diff --git a/public/test-refresh.html b/public/test-refresh.html new file mode 100644 index 0000000..e81695b --- /dev/null +++ b/public/test-refresh.html @@ -0,0 +1,192 @@ + + + + + + Test Refresh Button + + + + +

🔍 Test Refresh Button Functionality

+ +
+

Test Controls

+ + + + +
+ +
+

Cluster View (Simplified)

+
+
+
+
+ Primary Node: + 🔍 Discovering... +
+
+ +
+ +
+
Loading cluster members...
+
+
+
+ +
+

Debug Log

+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/test-state-preservation.html b/public/test-state-preservation.html new file mode 100644 index 0000000..8e1aed0 --- /dev/null +++ b/public/test-state-preservation.html @@ -0,0 +1,419 @@ + + + + + + SPORE UI - State Preservation Test + + + + +
+

🧪 SPORE UI State Preservation Test

+ +
+

Test Controls

+
+ + + + + + + + +
+ +
+

What This Test Demonstrates:

+
    +
  • State Preservation: When data is refreshed, expanded cards and active tabs are maintained
  • +
  • Partial Updates: Only changed data is updated, not entire components
  • +
  • UI State Persistence: User interactions (expanded cards, active tabs) are preserved across refreshes
  • +
  • Smart Updates: The system detects when data has actually changed and only updates what's necessary
  • +
+
+
+ +
+

Current State Indicators

+
+ Expanded Cards: + 0 +
+
+ Active Tabs: + 0 +
+
+ Last Update: + Never +
+
+ +
+

Test Log

+
+
Test log initialized. Use the test controls above to test state preservation.
+
+
+ + +
+
+

Primary Node

+
🔍 Discovering...
+ +
+ +
+

Cluster Members

+ +
+ +
+
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/test-tabs.html b/public/test-tabs.html new file mode 100644 index 0000000..4195d16 --- /dev/null +++ b/public/test-tabs.html @@ -0,0 +1,72 @@ + + + + + + Tab Test + + + +
+

Tab Active State Test

+ +
+
+ + + + +
+ +
+

Status Tab

+

This is the status tab content.

+
+ +
+

Endpoints Tab

+

This is the endpoints tab content.

+
+ +
+

Tasks Tab

+

This is the tasks tab content.

+
+ +
+

Firmware Tab

+

This is the firmware tab content.

+
+
+
+ + + + \ No newline at end of file diff --git a/public/test-view-switching.html b/public/test-view-switching.html new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/public/test-view-switching.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/view-models.js b/public/view-models.js new file mode 100644 index 0000000..711bdd1 --- /dev/null +++ b/public/view-models.js @@ -0,0 +1,393 @@ +// View Models for SPORE UI Components + +// Cluster View Model with enhanced state preservation +class ClusterViewModel extends ViewModel { + constructor() { + super(); + this.setMultiple({ + members: [], + primaryNode: null, + totalNodes: 0, + clientInitialized: false, + isLoading: false, + error: null, + expandedCards: new Map(), + activeTabs: new Map(), // Store active tab for each node + lastUpdateTime: null + }); + + // Initialize cluster status after a short delay to allow components to subscribe + setTimeout(() => { + this.updatePrimaryNodeDisplay(); + }, 100); + } + + // Update cluster members with state preservation + async updateClusterMembers() { + try { + console.log('ClusterViewModel: updateClusterMembers called'); + + // Store current UI state before update + const currentUIState = this.getAllUIState(); + const currentExpandedCards = this.get('expandedCards'); + const currentActiveTabs = this.get('activeTabs'); + + this.set('isLoading', true); + this.set('error', null); + + console.log('ClusterViewModel: Fetching cluster members...'); + const response = await window.apiClient.getClusterMembers(); + console.log('ClusterViewModel: Got response:', response); + + // Use batch update to preserve UI state + this.batchUpdate({ + members: response.members || [], + lastUpdateTime: new Date().toISOString() + }, { preserveUIState: true }); + + // Restore expanded cards and active tabs + this.set('expandedCards', currentExpandedCards); + this.set('activeTabs', currentActiveTabs); + + // Update primary node display + console.log('ClusterViewModel: Updating primary node display...'); + await this.updatePrimaryNodeDisplay(); + + } catch (error) { + console.error('ClusterViewModel: Failed to fetch cluster members:', error); + this.set('error', error.message); + } finally { + this.set('isLoading', false); + console.log('ClusterViewModel: updateClusterMembers completed'); + } + } + + // Update primary node display with state preservation + async updatePrimaryNodeDisplay() { + try { + const discoveryInfo = await window.apiClient.getDiscoveryInfo(); + + // Use batch update to preserve UI state + const updates = {}; + + if (discoveryInfo.primaryNode) { + updates.primaryNode = discoveryInfo.primaryNode; + updates.clientInitialized = discoveryInfo.clientInitialized; + updates.totalNodes = discoveryInfo.totalNodes; + } else if (discoveryInfo.totalNodes > 0) { + updates.primaryNode = discoveryInfo.nodes[0]?.ip; + updates.clientInitialized = false; + updates.totalNodes = discoveryInfo.totalNodes; + } else { + updates.primaryNode = null; + updates.clientInitialized = false; + updates.totalNodes = 0; + } + + this.batchUpdate(updates, { preserveUIState: true }); + + } catch (error) { + console.error('Failed to fetch discovery info:', error); + this.set('error', error.message); + } + } + + // Select random primary node + async selectRandomPrimaryNode() { + try { + const result = await window.apiClient.selectRandomPrimaryNode(); + + if (result.success) { + // Update the display after a short delay + setTimeout(() => { + this.updatePrimaryNodeDisplay(); + }, 1500); + + return result; + } else { + throw new Error(result.message || 'Random selection failed'); + } + } catch (error) { + console.error('Failed to select random primary node:', error); + throw error; + } + } + + // Store expanded card state + storeExpandedCard(memberIp, content) { + const expandedCards = this.get('expandedCards'); + expandedCards.set(memberIp, content); + this.set('expandedCards', expandedCards); + + // Also store in UI state for persistence + this.setUIState(`expanded_${memberIp}`, content); + } + + // Get expanded card state + getExpandedCard(memberIp) { + const expandedCards = this.get('expandedCards'); + return expandedCards.get(memberIp); + } + + // Clear expanded card state + clearExpandedCard(memberIp) { + const expandedCards = this.get('expandedCards'); + expandedCards.delete(memberIp); + this.set('expandedCards', expandedCards); + + // Also clear from UI state + this.clearUIState(`expanded_${memberIp}`); + } + + // Store active tab for a specific node + storeActiveTab(memberIp, tabName) { + const activeTabs = this.get('activeTabs'); + activeTabs.set(memberIp, tabName); + this.set('activeTabs', activeTabs); + + // Also store in UI state for persistence + this.setUIState(`activeTab_${memberIp}`, tabName); + } + + // Get active tab for a specific node + getActiveTab(memberIp) { + const activeTabs = this.get('activeTabs'); + return activeTabs.get(memberIp) || 'status'; // Default to 'status' tab + } + + // Check if data has actually changed to avoid unnecessary updates + hasDataChanged(newData, dataType) { + const currentData = this.get(dataType); + + if (Array.isArray(newData) && Array.isArray(currentData)) { + if (newData.length !== currentData.length) return true; + + // Compare each member's key properties + return newData.some((newMember, index) => { + const currentMember = currentData[index]; + return !currentMember || + newMember.ip !== currentMember.ip || + newMember.status !== currentMember.status || + newMember.latency !== currentMember.latency; + }); + } + + return newData !== currentData; + } + + // Smart update that only updates changed data + async smartUpdate() { + try { + console.log('ClusterViewModel: Performing smart update...'); + + // Fetch new data + const response = await window.apiClient.getClusterMembers(); + const newMembers = response.members || []; + + // Check if members data has actually changed + if (this.hasDataChanged(newMembers, 'members')) { + console.log('ClusterViewModel: Members data changed, updating...'); + await this.updateClusterMembers(); + } else { + console.log('ClusterViewModel: Members data unchanged, skipping update'); + // Still update primary node display as it might have changed + await this.updatePrimaryNodeDisplay(); + } + + } catch (error) { + console.error('ClusterViewModel: Smart update failed:', error); + this.set('error', error.message); + } + } +} + +// Node Details View Model with enhanced state preservation +class NodeDetailsViewModel extends ViewModel { + constructor() { + super(); + this.setMultiple({ + nodeStatus: null, + tasks: [], + isLoading: false, + error: null, + activeTab: 'status', + nodeIp: null + }); + } + + // Load node details with state preservation + async loadNodeDetails(ip) { + try { + // Store current UI state + const currentActiveTab = this.get('activeTab'); + + this.set('isLoading', true); + this.set('error', null); + this.set('nodeIp', ip); + + const nodeStatus = await window.apiClient.getNodeStatus(ip); + + // Use batch update to preserve UI state + this.batchUpdate({ + nodeStatus: nodeStatus + }, { preserveUIState: true }); + + // Restore active tab + this.set('activeTab', currentActiveTab); + + // Load tasks data + await this.loadTasksData(); + + } catch (error) { + console.error('Failed to load node details:', error); + this.set('error', error.message); + } finally { + this.set('isLoading', false); + } + } + + // Load tasks data with state preservation + async loadTasksData() { + try { + const ip = this.get('nodeIp'); + const response = await window.apiClient.getTasksStatus(ip); + this.set('tasks', response || []); + } catch (error) { + console.error('Failed to load tasks:', error); + this.set('tasks', []); + } + } + + // Set active tab with state persistence + setActiveTab(tabName) { + console.log('NodeDetailsViewModel: Setting activeTab to:', tabName); + this.set('activeTab', tabName); + + // Store in UI state for persistence + this.setUIState('activeTab', tabName); + } + + // Upload firmware + async uploadFirmware(file, nodeIp) { + try { + const result = await window.apiClient.uploadFirmware(file, nodeIp); + return result; + } catch (error) { + console.error('Firmware upload failed:', error); + throw error; + } + } +} + +// Firmware View Model +class FirmwareViewModel extends ViewModel { + constructor() { + super(); + this.setMultiple({ + selectedFile: null, + targetType: 'all', + specificNode: null, + availableNodes: [], + uploadProgress: null, + uploadResults: [], + isUploading: false + }); + } + + // Set selected file + setSelectedFile(file) { + this.set('selectedFile', file); + } + + // Set target type + setTargetType(type) { + this.set('targetType', type); + } + + // Set specific node + setSpecificNode(nodeIp) { + this.set('specificNode', nodeIp); + } + + // Update available nodes + updateAvailableNodes(nodes) { + this.set('availableNodes', nodes); + } + + // Start upload + startUpload() { + this.set('isUploading', true); + this.set('uploadProgress', { + current: 0, + total: 0, + status: 'Preparing...' + }); + this.set('uploadResults', []); + } + + // Update upload progress + updateUploadProgress(current, total, status) { + this.set('uploadProgress', { + current, + total, + status + }); + } + + // Add upload result + addUploadResult(result) { + const results = this.get('uploadResults'); + results.push(result); + this.set('uploadResults', results); + } + + // Complete upload + completeUpload() { + this.set('isUploading', false); + } + + // Reset upload state + resetUploadState() { + this.set('selectedFile', null); + this.set('uploadProgress', null); + this.set('uploadResults', []); + this.set('isUploading', false); + } + + // Check if deploy button should be enabled + isDeployEnabled() { + const hasFile = this.get('selectedFile') !== null; + const availableNodes = this.get('availableNodes'); + const hasAvailableNodes = availableNodes && availableNodes.length > 0; + + let isValidTarget = false; + if (this.get('targetType') === 'all') { + isValidTarget = hasAvailableNodes; + } else if (this.get('targetType') === 'specific') { + isValidTarget = hasAvailableNodes && this.get('specificNode'); + } + + return hasFile && isValidTarget && !this.get('isUploading'); + } +} + +// Navigation View Model +class NavigationViewModel extends ViewModel { + constructor() { + super(); + this.setMultiple({ + activeView: 'cluster', + views: ['cluster', 'firmware'] + }); + } + + // Set active view + setActiveView(viewName) { + this.set('activeView', viewName); + } + + // Get active view + getActiveView() { + return this.get('activeView'); + } +} \ No newline at end of file