// Node Details Component class NodeDetailsComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); this.suppressLoadingUI = false; } // Helper functions for color conversion rgbIntToHex(rgbInt) { if (!rgbInt && rgbInt !== 0) return '#000000'; const num = parseInt(rgbInt); if (isNaN(num)) return '#000000'; const r = (num >> 16) & 255; const g = (num >> 8) & 255; const b = num & 255; return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); } hexToRgbInt(hex) { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return 0; const r = parseInt(result[1], 16); const g = parseInt(result[2], 16); const b = parseInt(result[3], 16); return (r << 16) + (g << 8) + b; } // Format flash size in human readable format formatFlashSize(bytes) { if (!bytes || bytes === 0) return 'Unknown'; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } // Parameter component renderers renderSelectComponent(p, formId, pidx) { return ``; } renderColorComponent(p, formId, pidx) { const defaultValue = p.default !== undefined ? p.default : (Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : 0; return `
`; } renderNumberRangeComponent(p, formId, pidx) { const defaultValue = p.default !== undefined ? p.default : 0; const maxValue = p.value || 100; return `
${defaultValue} / ${maxValue}
`; } renderTextComponent(p, formId, pidx) { const defaultValue = p.default !== undefined ? p.default : (Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : ''; return ``; } renderBooleanComponent(p, formId, pidx) { const defaultValue = p.default !== undefined ? p.default : false; const checked = defaultValue ? 'checked' : ''; return `
`; } // Component map for parameter types getParameterComponentMap() { const components = { select: this.renderSelectComponent, color: this.renderColorComponent, numberRange: this.renderNumberRangeComponent, boolean: this.renderBooleanComponent, text: this.renderTextComponent }; // Bind all methods to this context return Object.fromEntries( Object.entries(components).map(([key, method]) => [key, method.bind(this)]) ); } // Component type determination rules getComponentType(p) { const typeRules = [ { condition: () => Array.isArray(p.values) && p.values.length > 1, type: 'select' }, { condition: () => p.type === 'color', type: 'color' }, { condition: () => p.type === 'numberRange', type: 'numberRange' }, { condition: () => p.type === 'boolean', type: 'boolean' } ]; const matchedRule = typeRules.find(rule => rule.condition()); return matchedRule ? matchedRule.type : 'text'; } // Main parameter renderer that uses the component map renderParameterComponent(p, formId, pidx) { const componentMap = this.getParameterComponentMap(); const componentType = this.getComponentType(p); const renderer = componentMap[componentType]; return renderer(p, formId, pidx); } 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 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 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 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 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); } } // Labels editor handlers handleAddLabel() { const keyInput = this.findElement('#label-key'); const valueInput = this.findElement('#label-value'); if (!keyInput || !valueInput) return; const key = keyInput.value.trim(); const value = valueInput.value.trim(); if (!key || !value) return; // Check if key already exists const existingLabel = this.findElement(`.label-item[data-key="${this.escapeHtml(key)}"]`); if (existingLabel) { this.showMessage(`Label '${key}' already exists`, 'warning'); return; } // Add the label to the UI this.addLabelToUI(key, value); // Clear inputs keyInput.value = ''; valueInput.value = ''; // Enable save button const saveBtn = this.findElement('.save-labels-btn'); if (saveBtn) { saveBtn.disabled = false; } this.showMessage(`Label '${key}' added`, 'success'); } handleRemoveLabel(key) { const labelItem = this.findElement(`.label-item[data-key="${this.escapeHtml(key)}"]`); if (labelItem) { labelItem.remove(); // Enable save button const saveBtn = this.findElement('.save-labels-btn'); if (saveBtn) { saveBtn.disabled = false; } this.showMessage(`Label '${key}' removed`, 'success'); } } async handleSaveLabels() { const saveBtn = this.findElement('.save-labels-btn'); if (!saveBtn) return; // Get current labels from UI const labels = this.getCurrentLabelsFromUI(); // Show loading state const originalText = saveBtn.innerHTML; saveBtn.disabled = true; saveBtn.innerHTML = ` Saving... `; try { const nodeIp = this.viewModel.get('nodeIp'); if (!nodeIp) { throw new Error('No node IP available'); } await window.apiClient.setNodeLabels(nodeIp, labels); // Disable save button saveBtn.disabled = true; saveBtn.innerHTML = originalText; this.showMessage('Labels saved successfully', 'success'); // Trigger a cluster update to refresh member cards with new labels // Add a small delay to allow the node to update its member list setTimeout(() => { this.triggerClusterUpdate(); }, 1000); // Don't refresh the entire node details - just update the labels in the current view // The labels are already updated in the UI, no need to reload everything } catch (error) { logger.error('Failed to save labels:', error); saveBtn.disabled = false; saveBtn.innerHTML = originalText; this.showMessage(`Failed to save labels: ${error.message}`, 'error'); } } addLabelToUI(key, value) { const labelsList = this.findElement('.labels-list'); if (!labelsList) return; // Remove no-labels message if it exists const noLabels = labelsList.querySelector('.no-labels'); if (noLabels) { noLabels.remove(); } const labelHtml = `
${this.escapeHtml(key)} = ${this.escapeHtml(value)}
`; labelsList.insertAdjacentHTML('beforeend', labelHtml); // Re-setup event listeners for the new remove button const newRemoveBtn = labelsList.querySelector(`.label-remove-btn[data-key="${this.escapeHtml(key)}"]`); if (newRemoveBtn) { this.addEventListener(newRemoveBtn, 'click', (e) => { const key = e.target.closest('.label-remove-btn').dataset.key; this.handleRemoveLabel(key); }); } } getCurrentLabelsFromUI() { const labels = {}; const labelItems = this.findAllElements('.label-item'); labelItems.forEach(item => { const key = item.dataset.key; const valueElement = item.querySelector('.label-value'); if (key && valueElement) { labels[key] = valueElement.textContent; } }); return labels; } showMessage(message, type = 'info') { // Create a temporary message element const messageEl = document.createElement('div'); messageEl.className = `labels-message labels-message-${type}`; messageEl.textContent = message; // Add to the labels section const labelsSection = this.findElement('.labels-section'); if (labelsSection) { labelsSection.appendChild(messageEl); // Remove after 3 seconds setTimeout(() => { if (messageEl.parentNode) { messageEl.remove(); } }, 3000); } } async triggerClusterUpdate() { try { // Trigger a server-side cluster refresh to update the UI await window.apiClient.request('/api/cluster/refresh', { method: 'POST', body: { reason: 'labels_updated' } }); } catch (error) { logger.error('Failed to trigger cluster refresh:', error); } } 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); // Labels are already shown in the member card header, so we don't need to show them again here const labelsBar = ''; const html = ` ${labelsBar}
${this.renderStatusTab(nodeStatus, monitoringResources)}
${this.renderLabelsTab(nodeStatus)}
${this.renderEndpointsTab(endpoints)}
${this.renderTasksTab(tasks)}
${this.renderFirmwareTab()}
`; this.setHTML('', html); this.setupTabs(); this.setupLabelsEditor(); 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 === 'labels' && nodeIp && typeof this.viewModel.loadNodeDetails === 'function') { // labels tab: refresh node details to get updated labels await this.viewModel.loadNodeDetails(nodeIp); } 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: ${this.formatFlashSize(nodeStatus.flashChipSize)}
`; // Add monitoring resources if available if (monitoringResources) { html += `
`; // 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); const usagePercent = monitoringResources.filesystem.total_bytes > 0 ? ((monitoringResources.filesystem.used_bytes / monitoringResources.filesystem.total_bytes) * 100).toFixed(1) : '0.0'; html += `
Filesystem: ${usagePercent}% (${usedKB}KB / ${totalKB}KB)
`; } // System Information if (monitoringResources.system) { html += `
Uptime: ${monitoringResources.system.uptime_formatted || 'N/A'}
`; } // Network Information if (monitoringResources.network) { const uptimeSeconds = monitoringResources.network.uptime_seconds || 0; const uptimeHours = Math.floor(uptimeSeconds / 3600); const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60); const uptimeFormatted = `${uptimeHours}h ${uptimeMinutes}m`; html += `
WiFi RSSI: ${monitoringResources.network.wifi_rssi || 'N/A'} dBm
Network Uptime: ${uptimeFormatted}
`; } 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 filesystemUsed = parseFloat(monitoringResources.filesystem?.used_bytes) || 0; const filesystemTotal = parseFloat(monitoringResources.filesystem?.total_bytes) || 0; const filesystemUsage = filesystemTotal > 0 ? (filesystemUsed / filesystemTotal) * 100 : 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
`; } renderLabelsTab(nodeStatus) { const labels = nodeStatus?.labels || {}; const labelsArray = Object.entries(labels); const labelsHTML = labelsArray.map(([key, value]) => `
${this.escapeHtml(key)} = ${this.escapeHtml(value)}
`).join(''); let html = `

Manage custom labels for this node. Labels help organize and identify nodes in your cluster.

=
${labelsArray.length === 0 ? `
No labels configured
Add labels to organize and identify this node
` : labelsHTML}
`; return html; } 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 => { let value = input.value; // For color type, convert hex to RGB integer if (input.dataset.paramType === 'color' && input.type === 'color') { const rgbDisplay = input.parentElement.querySelector('.color-rgb-display'); value = rgbDisplay ? rgbDisplay.value : this.hexToRgbInt(input.value); } // For boolean type, convert checkbox checked state to boolean else if (input.dataset.paramType === 'boolean' && input.type === 'checkbox') { value = input.checked; } return { name: input.dataset.paramName, location: input.dataset.paramLocation || 'body', type: input.dataset.paramType || 'string', required: input.dataset.paramRequired === '1', value: value }; }); // Required validation const missing = params.filter(p => p.required && (p.type === 'boolean' ? false : (!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 = `
${window.icon('success', { width: 14, height: 14 })} Success
${this.escapeHtml(pretty)}
`; } catch (err) { resultEl.innerHTML = `
${window.icon('error', { width: 14, height: 14 })} Error: ${this.escapeHtml(err.message || 'Request failed')}
`; } }); }); // Add event listeners for color pickers const colorPickers = this.findAllElements('.color-picker'); colorPickers.forEach(colorInput => { this.addEventListener(colorInput, 'input', (e) => { const rgbDisplay = colorInput.parentElement.querySelector('.color-rgb-display'); if (rgbDisplay) { const hexValue = e.target.value; const rgbInt = this.hexToRgbInt(hexValue); rgbDisplay.value = rgbInt; } }); }); // Update color picker when RGB display is manually changed (if we make it editable later) const rgbDisplays = this.findAllElements('.color-rgb-display'); rgbDisplays.forEach(rgbInput => { this.addEventListener(rgbInput, 'input', (e) => { const colorPicker = rgbInput.parentElement.querySelector('.color-picker'); if (colorPicker) { const rgbInt = parseInt(e.target.value) || 0; const hexValue = this.rgbIntToHex(rgbInt); colorPicker.value = hexValue; } }); }); // Add event listeners for range sliders const rangeSliders = this.findAllElements('.range-slider'); rangeSliders.forEach(rangeInput => { this.addEventListener(rangeInput, 'input', (e) => { const rangeDisplay = rangeInput.parentElement.querySelector('.range-display .range-value'); if (rangeDisplay) { rangeDisplay.textContent = e.target.value; } }); }); } 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 ? window.icon('dotGreen', { width: 10, height: 10 }) + ' Running' : window.icon('dotRed', { width: 10, height: 10 }) + ' Stopped'}
Interval: ${task.interval}ms ${task.enabled ? window.icon('dotGreen', { width: 10, height: 10 }) + ' Enabled' : window.icon('dotRed', { width: 10, height: 10 }) + ' Disabled'}
`).join(''); return ` ${summaryHTML} ${tasksHTML} `; } else { const total = summary?.totalTasks ?? 0; const active = summary?.activeTasks ?? 0; return `
${window.icon('file', { width: 16, height: 16 })}
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); } } }); } setupLabelsEditor() { // Add label functionality const addLabelBtn = this.findElement('.add-label-btn'); if (addLabelBtn) { this.addEventListener(addLabelBtn, 'click', () => this.handleAddLabel()); } // Remove label functionality const removeButtons = this.findAllElements('.label-remove-btn'); removeButtons.forEach(btn => { this.addEventListener(btn, 'click', (e) => { const key = e.target.closest('.label-remove-btn').dataset.key; this.handleRemoveLabel(key); }); }); // Save labels functionality const saveLabelsBtn = this.findElement('.save-labels-btn'); if (saveLabelsBtn) { this.addEventListener(saveLabelsBtn, 'click', () => this.handleSaveLabels()); } // Enter key support for adding labels const keyInput = this.findElement('#label-key'); const valueInput = this.findElement('#label-value'); if (keyInput) { this.addEventListener(keyInput, 'keypress', (e) => { if (e.key === 'Enter') { valueInput.focus(); } }); } if (valueInput) { this.addEventListener(valueInput, 'keypress', (e) => { if (e.key === 'Enter') { this.handleAddLabel(); } }); } // Input validation if (keyInput && valueInput) { const validateInputs = () => { const hasKey = keyInput.value.trim().length > 0; const hasValue = valueInput.value.trim().length > 0; addLabelBtn.disabled = !hasKey || !hasValue; }; this.addEventListener(keyInput, 'input', validateInputs); this.addEventListener(valueInput, 'input', validateInputs); } } // 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 = `
${window.icon('upload', { width: 14, height: 14 })} Uploading ${this.escapeHtml(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 = `
${window.icon('success', { width: 14, height: 14 })} 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 = `
${window.icon('error', { width: 14, height: 14 })} Upload failed: ${this.escapeHtml(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;