// 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, onlineNodes: 0 }); // 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); const members = response.members || []; const onlineNodes = Array.isArray(members) ? members.filter(m => m && m.status === 'active').length : 0; // Use batch update to preserve UI state this.batchUpdate({ members: members, lastUpdateTime: new Date().toISOString(), onlineNodes: onlineNodes }, { 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, capabilities: null, tasksSummary: 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(); // Load capabilities data await this.loadCapabilitiesData(); } 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 && Array.isArray(response.tasks)) ? response.tasks : []); this.set('tasksSummary', response && response.summary ? response.summary : null); } catch (error) { console.error('Failed to load tasks:', error); this.set('tasks', []); this.set('tasksSummary', null); } } // Load capabilities data with state preservation async loadCapabilitiesData() { try { const ip = this.get('nodeIp'); const response = await window.apiClient.getCapabilities(ip); this.set('capabilities', response || null); } catch (error) { console.error('Failed to load capabilities:', error); this.set('capabilities', null); } } // Invoke a capability against this node async callCapability(method, uri, params) { const ip = this.get('nodeIp'); return window.apiClient.callCapability({ ip, method, uri, params }); } // 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, selectedLabels: [], availableLabels: [] }); } // Set selected file setSelectedFile(file) { this.set('selectedFile', file); } // Set target type setTargetType(type) { this.set('targetType', type); // Clear any previously selected labels when switching options const currentLabels = this.get('selectedLabels') || []; if (currentLabels.length > 0) { this.set('selectedLabels', []); } } // Set specific node setSpecificNode(nodeIp) { this.set('specificNode', nodeIp); } // Update available nodes updateAvailableNodes(nodes) { this.set('availableNodes', nodes); // Compute availableLabels as unique key=value pairs from nodes' labels try { const labelSet = new Set(); (nodes || []).forEach(n => { const labels = n && n.labels ? n.labels : {}; Object.entries(labels).forEach(([k, v]) => { labelSet.add(`${k}=${v}`); }); }); const availableLabels = Array.from(labelSet).sort((a, b) => a.localeCompare(b)); this.set('availableLabels', availableLabels); // Prune selected labels that are no longer available const selected = this.get('selectedLabels') || []; const pruned = selected.filter(x => availableLabels.includes(x)); if (pruned.length !== selected.length) { this.set('selectedLabels', pruned); } } catch (_) { this.set('availableLabels', []); this.set('selectedLabels', []); } } // 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); } // Set selected labels setSelectedLabels(labels) { this.set('selectedLabels', Array.isArray(labels) ? labels : []); } // Return nodes matching ALL selected label pairs getAffectedNodesByLabels() { const selected = this.get('selectedLabels') || []; if (!selected.length) return []; const selectedPairs = selected.map(s => { const idx = String(s).indexOf('='); return idx > -1 ? { key: s.slice(0, idx), value: s.slice(idx + 1) } : null; }).filter(Boolean); const nodes = this.get('availableNodes') || []; return nodes.filter(n => { const labels = n && n.labels ? n.labels : {}; return selectedPairs.every(p => String(labels[p.key]) === String(p.value)); }); } // 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'); } else if (this.get('targetType') === 'labels') { const affected = this.getAffectedNodesByLabels(); isValidTarget = hasAvailableNodes && (this.get('selectedLabels') || []).length > 0 && affected.length > 0; } 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'); } } // Topology View Model for network topology visualization class TopologyViewModel extends ViewModel { constructor() { super(); this.setMultiple({ nodes: [], links: [], isLoading: false, error: null, lastUpdateTime: null, selectedNode: null }); } // Update network topology data async updateNetworkTopology() { try { console.log('TopologyViewModel: updateNetworkTopology called'); this.set('isLoading', true); this.set('error', null); // Get cluster members from the primary node const response = await window.apiClient.getClusterMembers(); console.log('TopologyViewModel: Got cluster members response:', response); const members = response.members || []; // Build enhanced graph data with actual node connections const { nodes, links } = await this.buildEnhancedGraphData(members); this.batchUpdate({ nodes: nodes, links: links, lastUpdateTime: new Date().toISOString() }); } catch (error) { console.error('TopologyViewModel: Failed to fetch network topology:', error); this.set('error', error.message); } finally { this.set('isLoading', false); console.log('TopologyViewModel: updateNetworkTopology completed'); } } // Build enhanced graph data with actual node connections async buildEnhancedGraphData(members) { const nodes = []; const links = []; const nodeConnections = new Map(); // Create nodes from members members.forEach((member, index) => { if (member && member.ip) { nodes.push({ id: member.ip, hostname: member.hostname || member.ip, ip: member.ip, status: member.status || 'UNKNOWN', latency: member.latency || 0, resources: member.resources || {}, x: Math.random() * 1200 + 100, // Better spacing for 1400px width y: Math.random() * 800 + 100 // Better spacing for 1000px height }); } }); // Try to get cluster members from each node to build actual connections for (const node of nodes) { try { const nodeResponse = await window.apiClient.getClusterMembersFromNode(node.ip); if (nodeResponse && nodeResponse.members) { nodeConnections.set(node.ip, nodeResponse.members); } } catch (error) { console.warn(`Failed to get cluster members from node ${node.ip}:`, error); // Continue with other nodes } } // Build links based on actual connections for (const [sourceIp, sourceMembers] of nodeConnections) { for (const targetMember of sourceMembers) { if (targetMember.ip && targetMember.ip !== sourceIp) { // Check if we already have this link const existingLink = links.find(link => (link.source === sourceIp && link.target === targetMember.ip) || (link.source === targetMember.ip && link.target === sourceIp) ); if (!existingLink) { const sourceNode = nodes.find(n => n.id === sourceIp); const targetNode = nodes.find(n => n.id === targetMember.ip); if (sourceNode && targetNode) { const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode); links.push({ source: sourceIp, target: targetMember.ip, latency: latency, sourceNode: sourceNode, targetNode: targetNode, bidirectional: true }); } } } } } // If no actual connections found, create a basic mesh if (links.length === 0) { console.log('TopologyViewModel: No actual connections found, creating basic mesh'); for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const sourceNode = nodes[i]; const targetNode = nodes[j]; const estimatedLatency = this.estimateLatency(sourceNode, targetNode); links.push({ source: sourceNode.id, target: targetNode.id, latency: estimatedLatency, sourceNode: sourceNode, targetNode: targetNode, bidirectional: true }); } } } return { nodes, links }; } // Estimate latency between two nodes estimateLatency(sourceNode, targetNode) { // Simple estimation - in a real implementation, you'd get actual measurements const baseLatency = 5; // Base latency in ms const randomVariation = Math.random() * 10; // Random variation return Math.round(baseLatency + randomVariation); } // Select a node in the graph selectNode(nodeId) { this.set('selectedNode', nodeId); } // Clear node selection clearSelection() { this.set('selectedNode', null); } }