// 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, topologyMode: 'mesh', // 'mesh' or 'star' starCenterNode: null // IP of the center node in star mode }); } // Set up WebSocket event listeners for real-time topology updates setupWebSocketListeners() { if (!window.wsClient) { logger.warn('TopologyViewModel: WebSocket client not available'); return; } // Listen for cluster updates window.wsClient.on('clusterUpdate', (data) => { logger.debug('TopologyViewModel: Received WebSocket cluster update:', data); // Update topology from WebSocket data if (data.members && Array.isArray(data.members)) { logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members, primary: ${data.primaryNode}`); // Build enhanced graph data from updated members with primary node info this.buildEnhancedGraphData(data.members, data.primaryNode).then(({ nodes, links }) => { this.batchUpdate({ nodes: nodes, links: links, primaryNode: data.primaryNode, lastUpdateTime: data.timestamp || new Date().toISOString() }); }).catch(error => { logger.error('TopologyViewModel: Failed to build graph data from websocket update:', error); }); } else { logger.warn('TopologyViewModel: Received cluster update but no valid members array:', data); } }); // Listen for node discovery events window.wsClient.on('nodeDiscovery', (data) => { logger.debug('TopologyViewModel: Received WebSocket node discovery event:', data); // Trigger animation for 'discovered' or 'active' actions (node is alive) // Skip animation for 'stale' or 'inactive' actions if (data.action === 'discovered' || data.action === 'active') { // Emit discovery animation event this.set('discoveryEvent', { nodeIp: data.nodeIp, timestamp: data.timestamp || new Date().toISOString(), action: data.action }); } }); // Listen for connection status changes window.wsClient.on('connected', () => { logger.debug('TopologyViewModel: WebSocket connected'); // Connection restored - the server will send a clusterUpdate event shortly // No need to make an API call, just wait for the websocket data }); window.wsClient.on('disconnected', () => { logger.debug('TopologyViewModel: WebSocket disconnected'); }); } // Update network topology data // NOTE: This method makes an API call and should only be used for initial load // All subsequent updates should come from websocket events (clusterUpdate) async updateNetworkTopology() { try { logger.debug('TopologyViewModel: updateNetworkTopology called (API call)'); 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); // Get discovery info to find the primary node const discoveryInfo = await window.apiClient.getDiscoveryInfo(); logger.debug('TopologyViewModel: Got discovery info:', discoveryInfo); const members = response.members || []; const primaryNode = discoveryInfo.primaryNode || null; logger.debug(`TopologyViewModel: Building graph with ${members.length} members, primary: ${primaryNode}`); // Build enhanced graph data with actual node connections const { nodes, links } = await this.buildEnhancedGraphData(members, primaryNode); logger.debug(`TopologyViewModel: Built graph with ${nodes.length} nodes and ${links.length} links`); this.batchUpdate({ nodes: nodes, links: links, primaryNode: primaryNode, 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 // Creates either a mesh topology (all nodes connected) or star topology (center node to all others) async buildEnhancedGraphData(members, primaryNode) { const nodes = []; const links = []; // Get existing nodes to preserve their positions const existingNodes = this.get('nodes') || []; const existingNodeMap = new Map(existingNodes.map(n => [n.id, n])); // Create nodes from members members.forEach((member, index) => { if (member && member.ip) { const existingNode = existingNodeMap.get(member.ip); const isPrimary = member.ip === primaryNode; nodes.push({ id: member.ip, hostname: member.hostname || member.ip, ip: member.ip, status: member.status || 'UNKNOWN', latency: member.latency || 0, isPrimary: isPrimary, // Preserve both legacy 'resources' and preferred 'labels' labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}), resources: member.resources || {}, // Preserve existing position if node already exists, otherwise assign random position x: existingNode ? existingNode.x : Math.random() * 1200 + 100, y: existingNode ? existingNode.y : Math.random() * 800 + 100, // Preserve velocity if it exists (for D3 simulation) vx: existingNode ? existingNode.vx : undefined, vy: existingNode ? existingNode.vy : undefined, // Preserve fixed position if it was dragged fx: existingNode ? existingNode.fx : undefined, fy: existingNode ? existingNode.fy : undefined }); } }); // Build links based on topology mode const topologyMode = this.get('topologyMode') || 'mesh'; if (topologyMode === 'mesh') { // Full mesh - connect all nodes to all other nodes logger.debug('TopologyViewModel: Creating full mesh topology'); 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]; links.push({ source: sourceNode.id, target: targetNode.id, latency: this.estimateLatency(sourceNode, targetNode), sourceNode: sourceNode, targetNode: targetNode, bidirectional: true }); } } logger.debug(`TopologyViewModel: Created ${links.length} links in mesh topology`); } else if (topologyMode === 'star') { // Star topology - center node connects to all others const centerNode = this.get('starCenterNode') || primaryNode; if (centerNode) { logger.debug(`TopologyViewModel: Creating star topology with center ${centerNode}`); nodes.forEach(node => { if (node.ip !== centerNode) { const member = members.find(m => m.ip === node.ip); const latency = member?.latency || this.estimateLatency(node, { ip: centerNode }); links.push({ source: centerNode, target: node.id, latency: latency, sourceNode: nodes.find(n => n.ip === centerNode), targetNode: node, bidirectional: false }); } }); logger.debug(`TopologyViewModel: Created ${links.length} links from center node`); } else { logger.warn('TopologyViewModel: No center node specified for star topology'); } } 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); } // Switch to a specific node as the new primary async switchToPrimaryNode(nodeIp) { try { logger.debug(`TopologyViewModel: Switching primary node to ${nodeIp}`); const result = await window.apiClient.setPrimaryNode(nodeIp); if (result.success) { logger.info(`TopologyViewModel: Successfully switched primary to ${nodeIp}`); // Update topology after a short delay to allow backend to update setTimeout(async () => { logger.debug('TopologyViewModel: Refreshing topology after primary switch...'); await this.updateNetworkTopology(); }, 1000); return result; } else { throw new Error(result.message || 'Failed to switch primary node'); } } catch (error) { logger.error('TopologyViewModel: Failed to switch primary node:', error); throw error; } } // Toggle topology mode or set star mode with specific center node async setTopologyMode(mode, centerNodeIp = null) { logger.debug(`TopologyViewModel: Setting topology mode to ${mode}`, centerNodeIp ? `with center ${centerNodeIp}` : ''); this.setMultiple({ topologyMode: mode, starCenterNode: centerNodeIp }); // Rebuild the graph with new topology await this.updateNetworkTopology(); } // Toggle between mesh and star modes async toggleTopologyMode(nodeIp) { const currentMode = this.get('topologyMode'); const currentCenter = this.get('starCenterNode'); if (currentMode === 'mesh') { // Switch to star mode with this node as center await this.setTopologyMode('star', nodeIp); } else if (currentMode === 'star' && currentCenter === nodeIp) { // Clicking same center node - switch back to mesh await this.setTopologyMode('mesh', null); } else { // Clicking different node - change star center await this.setTopologyMode('star', nodeIp); } } } // 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(); } } // Cluster Firmware Upload View Model class ClusterFirmwareViewModel extends ViewModel { constructor() { super(); this.setMultiple({ selectedFile: null, targetNodes: [], uploadProgress: null, uploadResults: [], isUploading: false }); } // Set selected file setSelectedFile(file) { this.set('selectedFile', file); } // Set target nodes (filtered from cluster view) setTargetNodes(nodes) { this.set('targetNodes', 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 is enabled isDeployEnabled() { const file = this.get('selectedFile'); const targetNodes = this.get('targetNodes'); const isUploading = this.get('isUploading'); return file && targetNodes && targetNodes.length > 0 && !isUploading; } } // WiFi Configuration View Model class WiFiConfigViewModel extends ViewModel { constructor() { super(); this.set('targetNodes', []); this.set('ssid', ''); this.set('password', ''); this.set('isConfiguring', false); this.set('configProgress', null); this.set('configResults', []); } // Set target nodes (filtered from cluster view) setTargetNodes(nodes) { this.set('targetNodes', nodes); } // Set WiFi credentials setCredentials(ssid, password) { this.set('ssid', ssid); this.set('password', password); } // Start configuration startConfiguration() { this.set('isConfiguring', true); this.set('configProgress', { current: 0, total: 0, status: 'Preparing...' }); this.set('configResults', []); } // Update configuration progress updateConfigProgress(current, total, status) { this.set('configProgress', { current, total, status }); } // Add configuration result addConfigResult(result) { const results = this.get('configResults'); results.push(result); this.set('configResults', results); } // Complete configuration completeConfiguration() { this.set('isConfiguring', false); this.set('configProgress', null); } // Reset configuration state resetConfiguration() { this.set('ssid', ''); this.set('password', ''); this.set('configProgress', null); this.set('configResults', []); this.set('isConfiguring', false); } // Check if apply is enabled isApplyEnabled() { const ssid = this.get('ssid'); const password = this.get('password'); const targetNodes = this.get('targetNodes'); const isConfiguring = this.get('isConfiguring'); return ssid && password && targetNodes && targetNodes.length > 0 && !isConfiguring; } } window.WiFiConfigViewModel = WiFiConfigViewModel; // Events View Model for websocket event visualization class EventViewModel extends ViewModel { constructor() { super(); this.setMultiple({ events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp } isLoading: false, error: null, lastUpdateTime: null }); // Set up WebSocket listeners for real-time updates this.setupWebSocketListeners(); } // Set up WebSocket event listeners setupWebSocketListeners() { if (!window.wsClient) { // Retry after a short delay to allow wsClient to initialize setTimeout(() => this.setupWebSocketListeners(), 1000); return; } // Listen for all websocket messages window.wsClient.on('message', (data) => { const topic = data.topic || data.type; if (topic) { this.addTopic(topic, data); } }); // Listen for connection status changes window.wsClient.on('connected', () => { logger.info('EventViewModel: WebSocket connected'); }); window.wsClient.on('disconnected', () => { logger.debug('EventViewModel: WebSocket disconnected'); }); } // Add a topic (parsed by "/" separator) addTopic(topic, data = null) { // Get current events as a new Map to ensure change detection const events = new Map(this.get('events')); // Handle nested events from cluster/event let fullTopic = topic; if (topic === 'cluster/event' && data && data.data) { try { const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data; if (parsedData && parsedData.event) { // Create nested topic chain: cluster/event/api/neopattern fullTopic = `${topic}/${parsedData.event}`; } } catch (e) { // If parsing fails, just use the original topic } } const parts = fullTopic.split('/').filter(p => p); if (events.has(fullTopic)) { // Update existing event - create new object to ensure change detection const existing = events.get(fullTopic); events.set(fullTopic, { topic: existing.topic, parts: existing.parts, count: existing.count + 1, firstSeen: existing.firstSeen, lastSeen: new Date().toISOString(), lastData: data }); } else { // Create new event entry events.set(fullTopic, { topic: fullTopic, parts: parts, count: 1, firstSeen: new Date().toISOString(), lastSeen: new Date().toISOString(), lastData: data }); } // Use set to trigger change notification this.set('events', events); this.set('lastUpdateTime', new Date().toISOString()); } // Get all topic parts (unique segments after splitting by "/") getTopicParts() { const events = this.get('events'); const allParts = new Set(); for (const [topic, data] of events) { data.parts.forEach(part => allParts.add(part)); } return Array.from(allParts).map(part => { // Count how many events contain this part let count = 0; for (const [topic, data] of events) { if (data.parts.includes(part)) { count += data.count; } } return { part, count }; }); } // Clear all events clearTopics() { this.set('events', new Map()); this.set('lastUpdateTime', new Date().toISOString()); } // Get connections between topic parts (for graph edges) getTopicConnections() { const topics = this.get('topics'); const connections = []; for (const [topic, data] of topics) { const parts = data.parts; // Create connections between adjacent parts for (let i = 0; i < parts.length - 1; i++) { connections.push({ source: parts[i], target: parts[i + 1], topic: topic, count: data.count }); } } return connections; } } window.EventViewModel = EventViewModel;