// 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 response = await window.apiClient.getTasksStatus(); 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'); } }