// View Models for SPORE UI Components // Cluster View Model 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); // Set up WebSocket listeners for real-time updates this.setupWebSocketListeners(); } // Set up WebSocket event listeners setupWebSocketListeners() { if (!window.wsClient) { logger.warn('WebSocket client not available'); return; } // Listen for cluster updates window.wsClient.on('clusterUpdate', (data) => { logger.debug('ClusterViewModel: Received WebSocket cluster update:', data); // Update members from WebSocket data if (data.members && Array.isArray(data.members)) { const onlineNodes = data.members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length; logger.debug(`ClusterViewModel: Updating members from ${this.get('members')?.length || 0} to ${data.members.length} members`); this.batchUpdate({ members: data.members, lastUpdateTime: data.timestamp || new Date().toISOString(), onlineNodes: onlineNodes }); // Update primary node display if it changed if (data.primaryNode !== this.get('primaryNode')) { logger.debug(`ClusterViewModel: Primary node changed from ${this.get('primaryNode')} to ${data.primaryNode}`); this.set('primaryNode', data.primaryNode); this.set('totalNodes', data.totalNodes || 0); } } else { logger.warn('ClusterViewModel: Received cluster update but no valid members array:', data); } }); // Listen for node discovery events window.wsClient.on('nodeDiscovery', (data) => { logger.debug('ClusterViewModel: Received WebSocket node discovery event:', data); if (data.action === 'discovered') { // A new node was discovered - trigger a cluster update setTimeout(() => { this.updateClusterMembers(); }, 500); } else if (data.action === 'stale') { // A node became stale - trigger a cluster update setTimeout(() => { this.updateClusterMembers(); }, 500); } }); // Listen for connection status changes window.wsClient.on('connected', () => { logger.debug('ClusterViewModel: WebSocket connected'); // Optionally trigger an immediate update when connection is restored setTimeout(() => { this.updateClusterMembers(); }, 1000); }); window.wsClient.on('disconnected', () => { logger.debug('ClusterViewModel: WebSocket disconnected'); }); } // Update cluster members async updateClusterMembers() { try { logger.debug('ClusterViewModel: updateClusterMembers called'); // Check if we have recent WebSocket data (within last 30 seconds) const lastUpdateTime = this.get('lastUpdateTime'); const now = new Date(); const websocketDataAge = lastUpdateTime ? (now - new Date(lastUpdateTime)) : Infinity; // If WebSocket data is recent, skip REST API call to avoid conflicts if (websocketDataAge < 30000 && this.get('members').length > 0) { logger.debug('ClusterViewModel: Using recent WebSocket data, skipping REST API call'); return; } // 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); logger.debug('ClusterViewModel: Fetching cluster members...'); const response = await window.apiClient.getClusterMembers(); logger.debug('ClusterViewModel: Got response:', response); const members = response.members || []; const onlineNodes = Array.isArray(members) ? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length : 0; // Use batch update this.batchUpdate({ members: members, lastUpdateTime: new Date().toISOString(), onlineNodes: onlineNodes }); // Restore expanded cards and active tabs this.set('expandedCards', currentExpandedCards); this.set('activeTabs', currentActiveTabs); // Update primary node display logger.debug('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); logger.debug('ClusterViewModel: updateClusterMembers completed'); } } // Update primary node display async updatePrimaryNodeDisplay() { try { const discoveryInfo = await window.apiClient.getDiscoveryInfo(); // Use batch update 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); } 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 { logger.debug('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')) { logger.debug('ClusterViewModel: Members data changed, updating...'); await this.updateClusterMembers(); } else { logger.debug('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 class NodeDetailsViewModel extends ViewModel { constructor() { super(); this.setMultiple({ nodeStatus: null, tasks: [], isLoading: false, error: null, activeTab: 'status', nodeIp: null, endpoints: null, tasksSummary: null, monitoringResources: null }); } // Load node details 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 this.batchUpdate({ nodeStatus: nodeStatus }); // Restore active tab this.set('activeTab', currentActiveTab); // Load tasks data await this.loadTasksData(); // Load endpoints data await this.loadEndpointsData(); // Load monitoring resources data await this.loadMonitoringResources(); } catch (error) { console.error('Failed to load node details:', error); this.set('error', error.message); } finally { this.set('isLoading', false); } } // Load tasks data 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 endpoints data async loadEndpointsData() { try { const ip = this.get('nodeIp'); const response = await window.apiClient.getEndpoints(ip); // Handle both real API (wrapped in endpoints) and mock API (direct array) const endpointsData = (response && response.endpoints) ? response : { endpoints: response }; this.set('endpoints', endpointsData || null); } catch (error) { console.error('Failed to load endpoints:', error); this.set('endpoints', null); } } // Load monitoring resources data async loadMonitoringResources() { try { const ip = this.get('nodeIp'); const response = await window.apiClient.getMonitoringResources(ip); // Handle both real API (wrapped in data) and mock API (direct response) const monitoringData = (response && response.data) ? response.data : response; this.set('monitoringResources', monitoringData); } catch (error) { console.error('Failed to load monitoring resources:', error); this.set('monitoringResources', null); } } // Invoke an endpoint against this node async callEndpoint(method, uri, params) { const ip = this.get('nodeIp'); return window.apiClient.callEndpoint({ ip, method, uri, params }); } // Set active tab with state persistence setActiveTab(tabName) { logger.debug('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', 'topology', 'firmware', 'monitoring'] }); } // 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 { logger.debug('TopologyViewModel: updateNetworkTopology called'); this.set('isLoading', true); this.set('error', null); // Get cluster members from the primary node const response = await window.apiClient.getClusterMembers(); logger.debug('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); logger.debug('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, // Preserve both legacy 'resources' and preferred 'labels' labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}), 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) { logger.debug('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); } } // Monitoring View Model for cluster resource monitoring class MonitoringViewModel extends ViewModel { constructor() { super(); this.setMultiple({ clusterMembers: [], nodeResources: new Map(), // Map of node IP -> resource data clusterSummary: { totalCpu: 0, totalMemory: 0, totalStorage: 0, totalNodes: 0, availableCpu: 0, availableMemory: 0, availableStorage: 0 }, isLoading: false, lastUpdated: null, error: null }); } // Load cluster members and their resource data async loadClusterData() { this.set('isLoading', true); this.set('error', null); try { // Get cluster members const response = await window.apiClient.getClusterMembers(); // Extract members array from response object const members = response.members || []; this.set('clusterMembers', members); // Load resource data for each node await this.loadNodeResources(members); // Calculate cluster summary this.calculateClusterSummary(); this.set('lastUpdated', new Date()); } catch (error) { console.error('Failed to load cluster monitoring data:', error); this.set('error', error.message || 'Failed to load monitoring data'); } finally { this.set('isLoading', false); } } // Load resource data for all nodes async loadNodeResources(members) { const nodeResources = new Map(); // Process nodes in parallel const resourcePromises = members.map(async (member) => { try { const resources = await window.apiClient.getMonitoringResources(member.ip); // Handle both real API (wrapped in data) and mock API (direct response) const resourceData = (resources && resources.data) ? resources.data : resources; nodeResources.set(member.ip, { ...member, resources: resourceData, hasResources: true, resourceSource: 'monitoring' }); } catch (error) { console.warn(`Failed to load monitoring resources for node ${member.ip}:`, error); // Fall back to basic resource data from cluster members API const basicResources = member.resources ? this.convertBasicResources(member.resources) : null; nodeResources.set(member.ip, { ...member, resources: basicResources, basic: member.resources, // Preserve original basic data hasResources: !!basicResources, resourceSource: basicResources ? 'basic' : 'none', error: basicResources ? null : error.message }); } }); await Promise.all(resourcePromises); this.set('nodeResources', nodeResources); } // Convert basic resource data from cluster members API to monitoring format convertBasicResources(basicResources) { // Convert ESP32 basic resources to monitoring format const freeHeap = basicResources.freeHeap || 0; const flashSize = basicResources.flashChipSize || 0; const cpuFreq = basicResources.cpuFreqMHz || 80; // Estimate total heap (ESP32 typically has ~300KB heap) const estimatedTotalHeap = 300 * 1024; // 300KB in bytes const usedHeap = estimatedTotalHeap - freeHeap; return { cpu: { total: cpuFreq, // Total CPU frequency in MHz available: cpuFreq * 0.8, // Estimate 80% available used: cpuFreq * 0.2 }, memory: { total: estimatedTotalHeap, available: freeHeap, used: usedHeap }, storage: { total: flashSize, available: flashSize * 0.5, // Estimate 50% available used: flashSize * 0.5 }, // Include original basic resources for reference basic: basicResources }; } // Calculate cluster resource summary calculateClusterSummary() { const nodeResources = this.get('nodeResources'); const members = this.get('clusterMembers'); let totalCpu = 0; let totalMemory = 0; let totalStorage = 0; let availableCpu = 0; let availableMemory = 0; let availableStorage = 0; let totalNodes = 0; for (const [ip, nodeData] of nodeResources) { if (nodeData.hasResources && nodeData.resources) { totalNodes++; // Extract resource data (handle both monitoring API and basic data formats) const resources = nodeData.resources; // CPU resources if (resources.cpu) { const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; // Get CPU frequency from basic resources if (nodeData.resourceSource === 'monitoring') { // Real monitoring API format totalCpu += cpuFreqMHz; // Total CPU frequency in MHz availableCpu += cpuFreqMHz * (100 - (resources.cpu.average_usage || 0)) / 100; // Available frequency } else { // Basic data format totalCpu += cpuFreqMHz; // Total CPU frequency in MHz availableCpu += cpuFreqMHz * (resources.cpu.available || 0.8); // Available frequency } } // Memory resources if (resources.memory) { if (nodeData.resourceSource === 'monitoring') { // Real monitoring API format totalMemory += resources.memory.total_heap || 0; availableMemory += resources.memory.free_heap || 0; } else { // Basic data format totalMemory += resources.memory.total || 0; availableMemory += resources.memory.available || 0; } } // Storage resources if (resources.filesystem || resources.storage) { const storage = resources.filesystem || resources.storage; if (nodeData.resourceSource === 'monitoring') { // Real monitoring API format totalStorage += storage.total_bytes || 0; availableStorage += storage.free_bytes || 0; } else { // Basic data format totalStorage += storage.total || 0; availableStorage += storage.available || 0; } } } } this.set('clusterSummary', { totalCpu, totalMemory, totalStorage, totalNodes, availableCpu, availableMemory, availableStorage }); } // Get resource utilization percentage getResourceUtilization(resourceType) { const summary = this.get('clusterSummary'); const total = summary[`total${resourceType}`]; const available = summary[`available${resourceType}`]; if (total === 0) return 0; return Math.round(((total - available) / total) * 100); } // Get formatted resource value formatResourceValue(value, type) { if (type === 'memory' || type === 'storage') { // Convert bytes to human readable format const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = value; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } else if (type === 'cpu') { // CPU is typically in cores or percentage return `${value} cores`; } return value.toString(); } // Refresh monitoring data async refresh() { await this.loadClusterData(); } }