// Node Details Component with enhanced state preservation class NodeDetailsComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); this.suppressLoadingUI = false; } 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)); this.subscribeToProperty('endpoints', this.handleEndpointsUpdate.bind(this)); this.subscribeToProperty('monitoringResources', this.handleMonitoringResourcesUpdate.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'), this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources')); } } // 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, this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources')); } } // Handle loading state update handleLoadingUpdate(isLoading) { if (isLoading) { if (this.suppressLoadingUI) return; this.renderLoading('
Loading detailed information...
'); } } // Handle error state update handleErrorUpdate(error) { if (error) { this.renderError(`Error loading node details: ${error}`); } } // Handle active tab update handleActiveTabUpdate(newTab, previousTab) { // Update tab UI without full re-render this.updateActiveTab(newTab, previousTab); } // Handle endpoints update with state preservation handleEndpointsUpdate(newEndpoints, previousEndpoints) { const nodeStatus = this.viewModel.get('nodeStatus'); const tasks = this.viewModel.get('tasks'); if (nodeStatus && !this.viewModel.get('isLoading')) { this.renderNodeDetails(nodeStatus, tasks, newEndpoints, this.viewModel.get('monitoringResources')); } } // Handle monitoring resources update with state preservation handleMonitoringResourcesUpdate(newResources, previousResources) { const nodeStatus = this.viewModel.get('nodeStatus'); const tasks = this.viewModel.get('tasks'); const endpoints = this.viewModel.get('endpoints'); if (nodeStatus && !this.viewModel.get('isLoading')) { this.renderNodeDetails(nodeStatus, tasks, endpoints, newResources); } } 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'); const endpoints = this.viewModel.get('endpoints'); const monitoringResources = this.viewModel.get('monitoringResources'); if (isLoading) { this.renderLoading('
Loading detailed information...
'); return; } if (error) { this.renderError(`Error loading node details: ${error}`); return; } if (!nodeStatus) { this.renderEmpty('
No node status available
'); return; } this.renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources); } renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources) { // Use persisted active tab from the view model, default to 'status' const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab); // Build labels bar (above tabs) const labelsObj = (nodeStatus && nodeStatus.labels) ? nodeStatus.labels : null; const labelsBar = (labelsObj && Object.keys(labelsObj).length) ? `
${Object.entries(labelsObj) .map(([k, v]) => `${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}`) .join('')}
` : ''; const html = ` ${labelsBar}
${this.renderStatusTab(nodeStatus, monitoringResources)}
${this.renderEndpointsTab(endpoints)}
${this.renderTasksTab(tasks)}
${this.renderFirmwareTab()}
`; this.setHTML('', html); this.setupTabs(); this.setupTabRefreshButton(); // Restore last active tab from view model if available const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null; if (restored) { this.setActiveTab(restored); } this.setupFirmwareUpload(); } setupTabRefreshButton() { const btn = this.findElement('.tab-refresh-btn'); if (!btn) return; this.addEventListener(btn, 'click', async (e) => { e.stopPropagation(); const original = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` `; try { const activeTab = (this.viewModel && typeof this.viewModel.get === 'function') ? (this.viewModel.get('activeTab') || 'status') : 'status'; const nodeIp = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('nodeIp') : null; this.suppressLoadingUI = true; if (activeTab === 'endpoints' && typeof this.viewModel.loadEndpointsData === 'function') { await this.viewModel.loadEndpointsData(); } else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') { await this.viewModel.loadTasksData(); } else if (activeTab === 'status' && typeof this.viewModel.loadMonitoringResources === 'function') { // status tab: load monitoring resources await this.viewModel.loadMonitoringResources(); } else { // firmware: refresh core node details if (nodeIp && typeof this.viewModel.loadNodeDetails === 'function') { await this.viewModel.loadNodeDetails(nodeIp); } } } catch (err) { logger.error('Tab refresh failed:', err); } finally { this.suppressLoadingUI = false; btn.disabled = false; btn.innerHTML = original; } }); } renderStatusTab(nodeStatus, monitoringResources) { let html = ''; // Add gauges section if monitoring resources are available if (monitoringResources) { html += this.renderResourceGauges(monitoringResources); } html += `
Chip ID: ${nodeStatus.chipId}
SDK Version: ${nodeStatus.sdkVersion}
CPU Frequency: ${nodeStatus.cpuFreqMHz}MHz
Flash Size: ${Math.round(nodeStatus.flashChipSize / 1024)}KB
`; // Add monitoring resources if available if (monitoringResources) { html += `
Resources
`; // CPU Usage if (monitoringResources.cpu) { html += `
CPU Usage (Avg): ${monitoringResources.cpu.average_usage ? monitoringResources.cpu.average_usage.toFixed(1) + '%' : 'N/A'}
`; } // Memory Usage if (monitoringResources.memory) { const heapUsagePercent = monitoringResources.memory.heap_usage_percent || 0; const totalHeap = monitoringResources.memory.total_heap || 0; const usedHeap = totalHeap - (monitoringResources.memory.free_heap || 0); const usedHeapKB = Math.round(usedHeap / 1024); const totalHeapKB = Math.round(totalHeap / 1024); html += `
Heap Usage: ${heapUsagePercent.toFixed(1)}% (${usedHeapKB}KB / ${totalHeapKB}KB)
`; } // Filesystem Usage if (monitoringResources.filesystem) { const usedKB = Math.round(monitoringResources.filesystem.used_bytes / 1024); const totalKB = Math.round(monitoringResources.filesystem.total_bytes / 1024); html += `
Filesystem: ${monitoringResources.filesystem.usage_percent ? monitoringResources.filesystem.usage_percent.toFixed(1) + '%' : 'N/A'} (${usedKB}KB / ${totalKB}KB)
`; } // System Uptime if (monitoringResources.system) { html += `
Uptime: ${monitoringResources.system.uptime_formatted || 'N/A'}
`; } html += `
`; } return html; } renderResourceGauges(monitoringResources) { // Get values with fallbacks and ensure they are numbers const cpuUsage = parseFloat(monitoringResources.cpu?.average_usage) || 0; const heapUsage = parseFloat(monitoringResources.memory?.heap_usage_percent) || 0; const filesystemUsage = parseFloat(monitoringResources.filesystem?.usage_percent) || 0; const filesystemUsed = parseFloat(monitoringResources.filesystem?.used_bytes) || 0; const filesystemTotal = parseFloat(monitoringResources.filesystem?.total_bytes) || 0; // Convert filesystem bytes to KB const filesystemUsedKB = Math.round(filesystemUsed / 1024); const filesystemTotalKB = Math.round(filesystemTotal / 1024); // Helper function to get color class based on percentage const getColorClass = (percentage) => { const numPercentage = parseFloat(percentage); if (numPercentage === 0 || isNaN(numPercentage)) return 'gauge-empty'; if (numPercentage < 50) return 'gauge-green'; if (numPercentage < 80) return 'gauge-yellow'; return 'gauge-red'; }; return `
${cpuUsage.toFixed(1)}%
CPU
${heapUsage.toFixed(1)}%
Heap
${filesystemUsage.toFixed(1)}%
Storage
`; } renderEndpointsTab(endpoints) { if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) { return `
🧩 No endpoints reported
This node did not return any endpoints
`; } // Sort endpoints by URI (name), then by method for stable ordering const endpointsList = [...endpoints.endpoints].sort((a, b) => { const aUri = String(a.uri || '').toLowerCase(); const bUri = String(b.uri || '').toLowerCase(); if (aUri < bUri) return -1; if (aUri > bUri) return 1; const aMethod = String(a.method || '').toLowerCase(); const bMethod = String(b.method || '').toLowerCase(); return aMethod.localeCompare(bMethod); }); const total = endpointsList.length; // Preserve selection based on a stable key of method+uri if available const selectedKey = String(this.getUIState('endpointSelectedKey') || ''); let selectedIndex = endpointsList.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey); if (selectedIndex === -1) { selectedIndex = Number(this.getUIState('endpointSelectedIndex')); if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) { selectedIndex = 0; } } // Compute padding for aligned display in dropdown const maxMethodLen = endpointsList.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0); const selectorOptions = endpointsList.map((ep, idx) => { const method = String(ep.method || ''); const uri = String(ep.uri || ''); const padCount = Math.max(1, (maxMethodLen - method.length) + 2); const spacer = ' '.repeat(padCount); return ``; }).join(''); const items = endpointsList.map((ep, idx) => { const formId = `endpoint-form-${idx}`; const resultId = `endpoint-result-${idx}`; const params = Array.isArray(ep.params) && ep.params.length > 0 ? `
${ep.params.map((p, pidx) => ` `).join('')}
` : '
No parameters
'; return `
${ep.method} ${ep.uri}
${params}
`; }).join(''); // Attach events after render in setupEndpointsEvents() setTimeout(() => this.setupEndpointsEvents(), 0); return `
${items}
`; } setupEndpointsEvents() { const selector = this.findElement('#endpoint-select'); if (selector) { this.addEventListener(selector, 'change', (e) => { const selected = Number(e.target.value); const items = Array.from(this.findAllElements('.endpoint-item')); items.forEach((el, idx) => { el.style.display = (idx === selected) ? '' : 'none'; }); this.setUIState('endpointSelectedIndex', selected); const opt = e.target.selectedOptions && e.target.selectedOptions[0]; if (opt) { const method = opt.dataset.method || ''; const uri = opt.dataset.uri || ''; this.setUIState('endpointSelectedKey', `${method} ${uri}`); } }); } const buttons = this.findAllElements('.endpoint-call-btn'); buttons.forEach(btn => { this.addEventListener(btn, 'click', async (e) => { e.stopPropagation(); const method = btn.dataset.method || 'GET'; const uri = btn.dataset.uri || ''; const formId = btn.dataset.formId; const resultId = btn.dataset.resultId; const formEl = this.findElement(`#${formId}`); const resultEl = this.findElement(`#${resultId}`); if (!formEl || !resultEl) return; const inputs = Array.from(formEl.querySelectorAll('.param-input')); const params = inputs.map(input => ({ name: input.dataset.paramName, location: input.dataset.paramLocation || 'body', type: input.dataset.paramType || 'string', required: input.dataset.paramRequired === '1', value: input.value })); // Required validation const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === '')); if (missing.length > 0) { resultEl.style.display = 'block'; resultEl.innerHTML = `
❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}
`; return; } // Show loading state resultEl.style.display = 'block'; resultEl.innerHTML = '
Calling endpoint...
'; try { const response = await this.viewModel.callEndpoint(method, uri, params); const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? ''); resultEl.innerHTML = `
✅ Success
${this.escapeHtml(pretty)}
`; } catch (err) { resultEl.innerHTML = `
❌ Error: ${this.escapeHtml(err.message || 'Request failed')}
`; } }); }); } escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>'); } renderTasksTab(tasks) { const summary = this.viewModel.get('tasksSummary'); if (tasks && tasks.length > 0) { const summaryHTML = summary ? `
📋
Tasks Overview
System task management and monitoring
${summary.totalTasks ?? tasks.length}
Total
${summary.activeTasks ?? tasks.filter(t => t.running).length}
Active
${(summary.totalTasks ?? tasks.length) - (summary.activeTasks ?? tasks.filter(t => t.running).length)}
Stopped
` : ''; const tasksHTML = tasks.map(task => `
${task.name || 'Unknown Task'} ${task.running ? '🟢 Running' : '🔴 Stopped'}
Interval: ${task.interval}ms ${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}
`).join(''); return ` ${summaryHTML} ${tasksHTML} `; } else { const total = summary?.totalTasks ?? 0; const active = summary?.activeTasks ?? 0; return `
📋
Tasks Overview
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
${total}
Total
${active}
Active
${total - active}
Stopped
📋 No active tasks found
${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
`; } } renderFirmwareTab() { return `

Firmware Update

Select a .bin or .hex file to upload
`; } setupTabs() { logger.debug('NodeDetailsComponent: Setting up tabs'); super.setupTabs(this.container, { onChange: (tab) => { // Persist active tab in the view model for restoration if (this.viewModel && typeof this.viewModel.setActiveTab === 'function') { this.viewModel.setActiveTab(tab); } } }); } // Update active tab without full re-render updateActiveTab(newTab, previousTab = null) { this.setActiveTab(newTab); logger.debug(`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 if available, otherwise fallback to view model state const memberCard = this.container.closest('.member-card'); let memberIp = null; if (memberCard && memberCard.dataset && memberCard.dataset.memberIp) { memberIp = memberCard.dataset.memberIp; } else if (this.viewModel && typeof this.viewModel.get === 'function') { memberIp = this.viewModel.get('nodeIp'); } 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
`; logger.debug('Firmware upload successful:', result); } catch (error) { logger.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 = ''; } } } } window.NodeDetailsComponent = NodeDetailsComponent;