// Node Details Component with enhanced state preservation class NodeDetailsComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); } 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('capabilities', this.handleCapabilitiesUpdate.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('capabilities')); } } // 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('capabilities')); } } // Handle loading state update handleLoadingUpdate(isLoading) { if (isLoading) { 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 capabilities update with state preservation handleCapabilitiesUpdate(newCapabilities, previousCapabilities) { const nodeStatus = this.viewModel.get('nodeStatus'); const tasks = this.viewModel.get('tasks'); if (nodeStatus && !this.viewModel.get('isLoading')) { this.renderNodeDetails(nodeStatus, tasks, newCapabilities); } } 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 capabilities = this.viewModel.get('capabilities'); 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, capabilities); } renderNodeDetails(nodeStatus, tasks, capabilities) { // 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); const html = `
Free Heap: ${Math.round(nodeStatus.freeHeap / 1024)}KB
Chip ID: ${nodeStatus.chipId}
SDK Version: ${nodeStatus.sdkVersion}
CPU Frequency: ${nodeStatus.cpuFreqMHz}MHz
Flash Size: ${Math.round(nodeStatus.flashChipSize / 1024)}KB
${nodeStatus.api ? nodeStatus.api.map(endpoint => `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` ).join('') : '
No API endpoints available
'}
${this.renderCapabilitiesTab(capabilities)}
${this.renderTasksTab(tasks)}
${this.renderFirmwareTab()}
`; this.setHTML('', html); this.setupTabs(); // 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(); } renderCapabilitiesTab(capabilities) { if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) { return `
🧩 No capabilities reported
This node did not return any capabilities
`; } // Sort endpoints by URI (name), then by method for stable ordering const endpoints = [...capabilities.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 = endpoints.length; // Preserve selection based on a stable key of method+uri if available const selectedKey = String(this.getUIState('capSelectedKey') || ''); let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey); if (selectedIndex === -1) { selectedIndex = Number(this.getUIState('capSelectedIndex')); if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) { selectedIndex = 0; } } // Compute padding for aligned display in dropdown const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0); const selectorOptions = endpoints.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 = endpoints.map((ep, idx) => { const formId = `cap-form-${idx}`; const resultId = `cap-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 setupCapabilitiesEvents() setTimeout(() => this.setupCapabilitiesEvents(), 0); return `
${items}
`; } setupCapabilitiesEvents() { const selector = this.findElement('#capability-select'); if (selector) { this.addEventListener(selector, 'change', (e) => { const selected = Number(e.target.value); const items = Array.from(this.findAllElements('.capability-item')); items.forEach((el, idx) => { el.style.display = (idx === selected) ? '' : 'none'; }); this.setUIState('capSelectedIndex', selected); const opt = e.target.selectedOptions && e.target.selectedOptions[0]; if (opt) { const method = opt.dataset.method || ''; const uri = opt.dataset.uri || ''; this.setUIState('capSelectedKey', `${method} ${uri}`); } }); } const buttons = this.findAllElements('.cap-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.callCapability(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 const memberCard = this.container.closest('.member-card'); const memberIp = memberCard.dataset.memberIp; 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;