// Firmware Component class FirmwareComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); logger.debug('FirmwareComponent: Constructor called'); logger.debug('FirmwareComponent: Container:', container); logger.debug('FirmwareComponent: Container ID:', container?.id); // Initialize overlay dialog this.overlayDialog = null; // Check if the dropdown exists in the container if (container) { const dropdown = container.querySelector('#specific-node-select'); logger.debug('FirmwareComponent: Dropdown found in constructor:', !!dropdown); if (dropdown) { logger.debug('FirmwareComponent: Dropdown tagName:', dropdown.tagName); logger.debug('FirmwareComponent: Dropdown id:', dropdown.id); } } } setupEventListeners() { // Setup global firmware file input const globalFirmwareFile = this.findElement('#global-firmware-file'); if (globalFirmwareFile) { this.addEventListener(globalFirmwareFile, 'change', this.handleFileSelect.bind(this)); } // Setup target selection const targetRadios = this.findAllElements('input[name="target-type"]'); targetRadios.forEach(radio => { this.addEventListener(radio, 'change', this.handleTargetChange.bind(this)); }); // Setup WebSocket listener for real-time firmware upload status this.setupWebSocketListeners(); // Setup specific node select change handler const specificNodeSelect = this.findElement('#specific-node-select'); logger.debug('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); if (specificNodeSelect) { logger.debug('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); logger.debug('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); logger.debug('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id); // Store the bound handler as an instance property this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler); logger.debug('FirmwareComponent: Event listener added to specificNodeSelect'); } else { logger.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); } // Setup label select change handler (single-select add-to-chips) const labelSelect = this.findElement('#label-select'); if (labelSelect) { this._boundLabelSelectHandler = (e) => { const value = e.target.value; if (!value) return; const current = this.viewModel.get('selectedLabels') || []; if (!current.includes(value)) { this.viewModel.setSelectedLabels([...current, value]); } // Reset select back to placeholder e.target.value = ''; this.renderSelectedLabelChips(); this.updateAffectedNodesPreview(); this.updateDeployButton(); }; this.addEventListener(labelSelect, 'change', this._boundLabelSelectHandler); } // Setup deploy button const deployBtn = this.findElement('#deploy-btn'); if (deployBtn) { this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this)); } } setupViewModelListeners() { this.subscribeToProperty('selectedFile', () => { this.updateFileInfo(); this.updateDeployButton(); }); this.subscribeToProperty('targetType', () => { this.updateTargetVisibility(); this.updateDeployButton(); this.updateAffectedNodesPreview(); }); this.subscribeToProperty('specificNode', this.updateDeployButton.bind(this)); this.subscribeToProperty('availableNodes', () => { this.populateNodeSelect(); this.populateLabelSelect(); this.updateDeployButton(); this.updateAffectedNodesPreview(); }); this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this)); this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); this.subscribeToProperty('isUploading', this.updateUploadState.bind(this)); this.subscribeToProperty('selectedLabels', () => { this.populateLabelSelect(); this.updateAffectedNodesPreview(); this.updateDeployButton(); }); } mount() { super.mount(); logger.debug('FirmwareComponent: Mounting...'); // Initialize overlay dialog this.initializeOverlayDialog(); // Check if the dropdown exists when mounted const dropdown = this.findElement('#specific-node-select'); logger.debug('FirmwareComponent: Mount - dropdown found:', !!dropdown); if (dropdown) { logger.debug('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); logger.debug('FirmwareComponent: Mount - dropdown id:', dropdown.id); logger.debug('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); } // Initialize target visibility and label list on first mount try { this.updateTargetVisibility(); this.populateLabelSelect(); this.updateAffectedNodesPreview(); } catch (e) { logger.warn('FirmwareComponent: Initialization after mount failed:', e); } logger.debug('FirmwareComponent: Mounted successfully'); } render() { // Initial render is handled by the HTML template this.updateDeployButton(); } initializeOverlayDialog() { // Create overlay container if it doesn't exist let overlayContainer = document.getElementById('firmware-overlay-dialog'); if (!overlayContainer) { overlayContainer = document.createElement('div'); overlayContainer.id = 'firmware-overlay-dialog'; overlayContainer.className = 'overlay-dialog'; document.body.appendChild(overlayContainer); } // Create and initialize the overlay dialog component if (!this.overlayDialog) { const overlayVM = new ViewModel(); this.overlayDialog = new OverlayDialogComponent(overlayContainer, overlayVM, this.eventBus); this.overlayDialog.mount(); } } handleFileSelect(event) { const file = event.target.files[0]; this.viewModel.setSelectedFile(file); } handleTargetChange(event) { const targetType = event.target.value; this.viewModel.setTargetType(targetType); } handleNodeSelect(event) { const nodeIp = event.target.value; logger.debug('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); logger.debug('Event:', event); logger.debug('Event target:', event.target); logger.debug('Event target value:', event.target.value); this.viewModel.setSpecificNode(nodeIp); // Also update the deploy button state this.updateDeployButton(); } async handleDeploy() { const file = this.viewModel.get('selectedFile'); const targetType = this.viewModel.get('targetType'); const specificNode = this.viewModel.get('specificNode'); if (!file) { this.showConfirmationDialog({ title: 'No File Selected', message: 'Please select a firmware file first.', confirmText: 'OK', cancelText: null, onConfirm: () => {}, onCancel: null }); return; } if (targetType === 'specific' && !specificNode) { this.showConfirmationDialog({ title: 'No Node Selected', message: 'Please select a specific node to update.', confirmText: 'OK', cancelText: null, onConfirm: () => {}, onCancel: null }); return; } // Show confirmation dialog for deployment this.showDeploymentConfirmation(file, targetType, specificNode); } showConfirmationDialog(options) { if (!this.overlayDialog) { this.initializeOverlayDialog(); } this.overlayDialog.show(options); } showDeploymentConfirmation(file, targetType, specificNode) { let title, message; if (targetType === 'all') { const nodes = this.viewModel.get('availableNodes') || []; title = 'Deploy to All Nodes'; message = `Upload firmware "${file.name}" to all ${nodes.length} nodes?

This will update:
${nodes.map(n => `• ${n.hostname || n.ip}`).join('
')}`; } else if (targetType === 'specific') { title = 'Deploy to Specific Node'; message = `Upload firmware "${file.name}" to node ${specificNode}?`; } else if (targetType === 'labels') { const nodes = this.viewModel.getAffectedNodesByLabels(); const labels = this.viewModel.get('selectedLabels') || []; title = 'Deploy to Labeled Nodes'; message = `Upload firmware "${file.name}" to ${nodes.length} node(s) matching labels (${labels.join(', ')})?

This will update:
${nodes.map(n => `• ${n.hostname || n.ip}`).join('
')}`; } this.showConfirmationDialog({ title: title, message: message, confirmText: 'Deploy', cancelText: 'Cancel', onConfirm: () => this.performDeployment(file, targetType, specificNode), onCancel: () => {} }); } async performDeployment(file, targetType, specificNode) { try { this.viewModel.startUpload(); if (targetType === 'all') { await this.uploadToAllNodes(file); } else if (targetType === 'specific') { await this.uploadToSpecificNode(file, specificNode); } else if (targetType === 'labels') { await this.uploadToLabelFilteredNodes(file); } // NOTE: Don't reset upload state here! // The upload state should remain active until websocket confirms completion // Status updates and finalization happen via websocket messages in checkAndFinalizeUploadResults() logger.debug('Firmware upload HTTP requests completed, waiting for websocket status updates'); } catch (error) { logger.error('Firmware deployment failed:', error); this.showConfirmationDialog({ title: 'Deployment Failed', message: `Deployment failed: ${error.message}`, confirmText: 'OK', cancelText: null, onConfirm: () => {}, onCancel: null }); // Only complete upload on error this.viewModel.completeUpload(); } } async uploadToAllNodes(file) { try { // Get current cluster members const response = await window.apiClient.getClusterMembers(); const nodes = response.members || []; if (nodes.length === 0) { this.showConfirmationDialog({ title: 'No Nodes Available', message: 'No nodes available for firmware update.', confirmText: 'OK', cancelText: null, onConfirm: () => {}, onCancel: null }); return; } // Show upload progress area this.showUploadProgress(file, nodes); // Start batch upload const results = await this.performBatchUpload(file, nodes); // Don't display results here - wait for websocket to confirm all uploads complete logger.debug('Batch upload HTTP requests completed, waiting for websocket confirmations'); } catch (error) { logger.error('Failed to upload firmware to all nodes:', error); throw error; } } async uploadToSpecificNode(file, nodeIp) { try { // Show upload progress area this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]); // Note: Status updates will come via websocket messages // We don't update progress here as the HTTP response is just an acknowledgment // Perform single node upload (this sends the file and gets acknowledgment) const result = await this.performSingleUpload(file, nodeIp); // Don't immediately mark as completed - wait for websocket status updates logger.debug(`Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); } catch (error) { logger.error(`Failed to upload firmware to node ${nodeIp}:`, error); // For HTTP errors, we can immediately mark as failed since the upload didn't start this.updateNodeProgress(1, 1, nodeIp, 'Failed'); this.updateOverallProgress(0, 1); // Display error results const errorResult = { nodeIp: nodeIp, hostname: nodeIp, success: false, error: error.message, timestamp: new Date().toISOString() }; this.displayUploadResults([errorResult]); throw error; } } async uploadToLabelFilteredNodes(file) { try { const nodes = this.viewModel.getAffectedNodesByLabels(); if (!nodes || nodes.length === 0) { this.showConfirmationDialog({ title: 'No Matching Nodes', message: 'No nodes match the selected labels.', confirmText: 'OK', cancelText: null, onConfirm: () => {}, onCancel: null }); return; } // Show upload progress area this.showUploadProgress(file, nodes); // Start batch upload const results = await this.performBatchUpload(file, nodes); // Don't display results here - wait for websocket to confirm all uploads complete logger.debug('Label-filtered upload HTTP requests completed, waiting for websocket confirmations'); } catch (error) { logger.error('Failed to upload firmware to label-filtered nodes:', error); throw error; } } async performBatchUpload(file, nodes) { const results = []; const totalNodes = nodes.length; let successfulUploads = 0; // Initialize all nodes as uploading first for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const nodeIp = node.ip; this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); } for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const nodeIp = node.ip; try { // Upload to this node (HTTP call just initiates the upload) const result = await this.performSingleUpload(file, nodeIp); // Don't immediately mark as completed - wait for websocket status logger.debug(`Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); results.push(result); } catch (error) { logger.error(`Failed to upload to node ${nodeIp}:`, error); const errorResult = { nodeIp: nodeIp, hostname: node.hostname || nodeIp, success: false, error: error.message, timestamp: new Date().toISOString() }; results.push(errorResult); // For HTTP errors, we can immediately mark as failed since the upload didn't start this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed'); } // Small delay between uploads if (i < nodes.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } return results; } async performSingleUpload(file, nodeIp) { try { const result = await window.apiClient.uploadFirmware(file, nodeIp); // IMPORTANT: This HTTP response is just an acknowledgment that the gateway received the file // The actual firmware processing happens asynchronously on the device // Status updates will come via WebSocket messages, NOT from this HTTP response logger.debug(`HTTP acknowledgment received for ${nodeIp}:`, result); logger.debug(`This does NOT mean upload is complete - waiting for WebSocket status updates`); return { nodeIp: nodeIp, hostname: nodeIp, httpAcknowledged: true, // Changed from 'success' to make it clear this is just HTTP ack result: result, timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Upload to ${nodeIp} failed: ${error.message}`); } } showUploadProgress(file, nodes) { const container = this.findElement('#firmware-nodes-list'); const progressHTML = `

Firmware Upload Progress

File: ${file.name} Size: ${(file.size / 1024).toFixed(1)}KB Targets: ${nodes.length} node(s)
0/${nodes.length} Successful (0%)
Status: Upload in progress...
${nodes.map(node => `
${node.hostname || node.ip} ${node.ip}
Uploading...
`).join('')}
`; container.innerHTML = progressHTML; // Initialize progress for single-node uploads if (nodes.length === 1) { const node = nodes[0]; this.updateNodeProgress(1, 1, node.ip, 'Uploading...'); } } updateNodeProgress(current, total, nodeIp, status) { const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`); if (progressItem) { const statusElement = progressItem.querySelector('.progress-status'); const timeElement = progressItem.querySelector('.progress-time'); if (statusElement) { statusElement.textContent = status; // Add status-specific styling statusElement.className = 'progress-status'; if (status === 'Completed') { statusElement.classList.add('success'); if (timeElement) { timeElement.textContent = new Date().toLocaleTimeString(); } } else if (status === 'Failed') { statusElement.classList.add('error'); if (timeElement) { timeElement.textContent = new Date().toLocaleTimeString(); } } else if (status === 'Uploading...') { statusElement.classList.add('uploading'); if (timeElement) { timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString(); } } } } } updateOverallProgress(successfulUploads, totalNodes) { const progressBar = this.findElement('#overall-progress-bar'); const progressText = this.findElement('.progress-text'); if (progressBar && progressText) { const successPercentage = Math.round((successfulUploads / totalNodes) * 100); progressBar.style.width = `${successPercentage}%`; progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; // Update progress bar color based on completion if (successPercentage === 100) { progressBar.style.backgroundColor = '#4ade80'; } else if (successPercentage > 50) { progressBar.style.backgroundColor = '#60a5fa'; } else { progressBar.style.backgroundColor = '#fbbf24'; } // NOTE: Don't update progress summary here for single-node uploads // The summary should only be updated via websocket status updates // This prevents premature "completed successfully" messages } } displayUploadResults(results) { const progressHeader = this.findElement('.progress-header h3'); const progressSummary = this.findElement('#progress-summary'); if (progressHeader && progressSummary) { const successCount = results.filter(r => r.success).length; const totalCount = results.length; const successRate = Math.round((successCount / totalCount) * 100); if (totalCount === 1) { // Single node upload if (successCount === 1) { progressHeader.textContent = `Firmware Upload Complete`; progressSummary.innerHTML = `${window.icon('success', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}`; } else { progressHeader.textContent = `Firmware Upload Failed`; progressSummary.innerHTML = `${window.icon('error', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}`; } } else if (successCount === totalCount) { // Multi-node upload - all successful progressHeader.textContent = `Firmware Upload Complete (${successCount}/${totalCount} Successful)`; progressSummary.innerHTML = `${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}`; } else { // Multi-node upload - some failed progressHeader.textContent = `Firmware Upload Results (${successCount}/${totalCount} Successful)`; progressSummary.innerHTML = `${window.icon('warning', { width: 14, height: 14 })} Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`; } } } updateFileInfo() { const file = this.viewModel.get('selectedFile'); const fileInfo = this.findElement('#file-info'); const deployBtn = this.findElement('#deploy-btn'); if (file) { fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`; fileInfo.classList.add('has-file'); } else { fileInfo.textContent = 'No file selected'; fileInfo.classList.remove('has-file'); } this.updateDeployButton(); } updateTargetVisibility() { const targetType = this.viewModel.get('targetType'); const specificNodeSelect = this.findElement('#specific-node-select'); const labelSelect = this.findElement('#label-select'); logger.debug('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); if (targetType === 'specific') { if (specificNodeSelect) { specificNodeSelect.style.display = 'inline-block'; } if (labelSelect) { labelSelect.style.display = 'none'; } this.populateNodeSelect(); } else if (targetType === 'labels') { if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } if (labelSelect) { labelSelect.style.display = 'inline-block'; this.populateLabelSelect(); } } else { if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } if (labelSelect) { labelSelect.style.display = 'none'; } } this.updateDeployButton(); } // Note: handleNodeSelect is already defined above and handles the actual node selection // This duplicate method was causing the issue - removing it updateDeployButton() { const deployBtn = this.findElement('#deploy-btn'); if (deployBtn) { deployBtn.disabled = !this.viewModel.isDeployEnabled(); } } setupWebSocketListeners() { // Listen for real-time firmware upload status updates window.wsClient.on('firmwareUploadStatus', (data) => { this.handleFirmwareUploadStatus(data); }); } handleFirmwareUploadStatus(data) { const { nodeIp, status, filename, fileSize, timestamp } = data; logger.debug('Firmware upload status received:', { nodeIp, status, filename, timestamp: new Date(timestamp).toLocaleTimeString() }); // Check if there's currently an upload in progress const isUploading = this.viewModel.get('isUploading'); if (!isUploading) { logger.debug('No active upload, ignoring status update'); return; } // Find the progress item for this node const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`); if (!progressItem) { logger.debug('No progress item found for node:', nodeIp); return; } // Update the status display based on the received status const statusElement = progressItem.querySelector('.progress-status'); const timeElement = progressItem.querySelector('.progress-time'); if (statusElement) { let displayStatus = status; let statusClass = ''; logger.debug(`Updating status for node ${nodeIp}: ${status} -> ${displayStatus}`); switch (status) { case 'uploading': displayStatus = 'Uploading...'; statusClass = 'uploading'; break; case 'completed': displayStatus = 'Completed'; statusClass = 'success'; logger.debug(`Node ${nodeIp} marked as completed`); break; case 'failed': displayStatus = 'Failed'; statusClass = 'error'; break; default: displayStatus = status; break; } statusElement.textContent = displayStatus; statusElement.className = `progress-status ${statusClass}`; // Update timestamp for completed/failed uploads if ((status === 'completed' || status === 'failed') && timeElement) { timeElement.textContent = new Date(timestamp).toLocaleTimeString(); } else if (status === 'uploading' && timeElement) { timeElement.textContent = 'Started: ' + new Date(timestamp).toLocaleTimeString(); } } // Update overall progress if we have multiple nodes this.updateOverallProgressFromStatus(); // Check if all uploads are complete and finalize results this.checkAndFinalizeUploadResults(); } checkAndFinalizeUploadResults() { const progressItems = this.findAllElements('.progress-item'); if (progressItems.length === 0) return; // Check if all uploads are complete (either completed or failed) let allComplete = true; let hasAnyCompleted = false; let hasAnyFailed = false; let uploadingCount = 0; const statuses = []; progressItems.forEach(item => { const statusElement = item.querySelector('.progress-status'); if (statusElement) { const status = statusElement.textContent; statuses.push(status); if (status !== 'Completed' && status !== 'Failed') { allComplete = false; if (status === 'Uploading...') { uploadingCount++; } } if (status === 'Completed') { hasAnyCompleted = true; } if (status === 'Failed') { hasAnyFailed = true; } } }); logger.debug('Upload status check:', { totalItems: progressItems.length, allComplete, uploadingCount, hasAnyCompleted, hasAnyFailed, statuses }); // If all uploads are complete, finalize the results if (allComplete) { logger.debug('All firmware uploads complete, finalizing results'); // Generate results based on current status const results = progressItems.map(item => { const nodeIp = item.getAttribute('data-node-ip'); const nodeName = item.querySelector('.node-name')?.textContent || nodeIp; const statusElement = item.querySelector('.progress-status'); const status = statusElement?.textContent || 'Unknown'; return { nodeIp: nodeIp, hostname: nodeName, success: status === 'Completed', error: status === 'Failed' ? 'Upload failed' : undefined, timestamp: new Date().toISOString() }; }); // Update the header and summary to show final results this.displayUploadResults(results); // Now that all uploads are truly complete (confirmed via websocket), mark upload as complete this.viewModel.completeUpload(); // Reset upload state after a short delay to allow user to see results setTimeout(() => { this.viewModel.resetUploadState(); }, 5000); } else if (uploadingCount > 0) { logger.debug(`${uploadingCount} uploads still in progress, not finalizing yet`); } else { logger.debug('Some uploads may have unknown status, but not finalizing yet'); } } updateOverallProgressFromStatus() { const progressItems = this.findAllElements('.progress-item'); if (progressItems.length <= 1) { return; // Only update for multi-node uploads } let completedCount = 0; let failedCount = 0; let uploadingCount = 0; progressItems.forEach(item => { const statusElement = item.querySelector('.progress-status'); if (statusElement) { const status = statusElement.textContent; if (status === 'Completed') { completedCount++; } else if (status === 'Failed') { failedCount++; } else if (status === 'Uploading...') { uploadingCount++; } } }); const totalNodes = progressItems.length; const successfulUploads = completedCount; const successPercentage = Math.round((successfulUploads / totalNodes) * 100); // Update overall progress bar const progressBar = this.findElement('#overall-progress-bar'); const progressText = this.findElement('.progress-text'); if (progressBar && progressText) { progressBar.style.width = `${successPercentage}%`; // Update progress bar color based on completion if (successPercentage === 100) { progressBar.style.backgroundColor = '#4ade80'; } else if (successPercentage > 50) { progressBar.style.backgroundColor = '#60a5fa'; } else { progressBar.style.backgroundColor = '#fbbf24'; } progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; } // Update progress summary const progressSummary = this.findElement('#progress-summary'); if (progressSummary) { if (failedCount > 0) { progressSummary.innerHTML = `${window.icon('warning', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading, ${failedCount} failed)`; } else if (uploadingCount > 0) { progressSummary.innerHTML = `${window.icon('info', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading)`; } else if (completedCount === totalNodes) { progressSummary.innerHTML = `${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}`; } } } populateNodeSelect() { const select = this.findElement('#specific-node-select'); if (!select) { logger.warn('FirmwareComponent: populateNodeSelect - select element not found'); return; } if (select.tagName !== 'SELECT') { logger.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); return; } logger.debug('FirmwareComponent: populateNodeSelect called'); logger.debug('FirmwareComponent: Select element:', select); logger.debug('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes')); // Clear existing options select.innerHTML = ''; // Get available nodes from the view model const availableNodes = this.viewModel.get('availableNodes'); if (!availableNodes || availableNodes.length === 0) { // No nodes available const option = document.createElement('option'); option.value = ""; option.textContent = "No nodes available"; option.disabled = true; select.appendChild(option); return; } availableNodes.forEach(node => { const option = document.createElement('option'); option.value = node.ip; option.textContent = `${node.hostname} (${node.ip})`; select.appendChild(option); }); // Ensure event listener is still bound after repopulating this.ensureNodeSelectListener(select); logger.debug('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes'); } // Ensure the node select change listener is properly bound ensureNodeSelectListener(select) { if (!select) return; // Store the bound handler as an instance property to avoid binding issues if (!this._boundNodeSelectHandler) { this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); } // Remove any existing listeners and add the bound one select.removeEventListener('change', this._boundNodeSelectHandler); select.addEventListener('change', this._boundNodeSelectHandler); logger.debug('FirmwareComponent: Node select event listener ensured'); } updateUploadProgress() { // This will be implemented when we add upload progress tracking } updateUploadResults() { // This will be implemented when we add upload results display } updateUploadState() { const isUploading = this.viewModel.get('isUploading'); const deployBtn = this.findElement('#deploy-btn'); if (deployBtn) { deployBtn.disabled = isUploading; if (isUploading) { deployBtn.classList.add('loading'); deployBtn.textContent = '⏳ Deploying...'; } else { deployBtn.classList.remove('loading'); deployBtn.textContent = '🚀 Deploy'; } } this.updateDeployButton(); } populateLabelSelect() { const select = this.findElement('#label-select'); if (!select) return; const labels = this.viewModel.get('availableLabels') || []; const selected = new Set(this.viewModel.get('selectedLabels') || []); const options = [''] .concat(labels.filter(l => !selected.has(l)).map(l => ``)); select.innerHTML = options.join(''); // Ensure change listener remains bound if (this._boundLabelSelectHandler) { select.removeEventListener('change', this._boundLabelSelectHandler); select.addEventListener('change', this._boundLabelSelectHandler); } this.renderSelectedLabelChips(); } renderSelectedLabelChips() { const container = this.findElement('#selected-labels-container'); if (!container) return; const selected = this.viewModel.get('selectedLabels') || []; if (selected.length === 0) { container.innerHTML = ''; return; } container.innerHTML = selected.map(l => ` ${l} `).join(''); Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => { this.addEventListener(btn, 'click', (e) => { e.stopPropagation(); const label = btn.getAttribute('data-label'); const current = this.viewModel.get('selectedLabels') || []; this.viewModel.setSelectedLabels(current.filter(x => x !== label)); this.populateLabelSelect(); this.updateAffectedNodesPreview(); this.updateDeployButton(); }); }); } updateAffectedNodesPreview() { const container = this.findElement('#firmware-nodes-list'); if (!container) return; if (this.viewModel.get('targetType') !== 'labels') { container.innerHTML = ''; return; } const nodes = this.viewModel.getAffectedNodesByLabels(); if (!nodes.length) { container.innerHTML = `
No nodes match the selected labels
`; return; } const html = `

Affected Nodes (${nodes.length})

${nodes.map(n => `
${n.hostname || n.ip}${n.ip}
`).join('')}
`; container.innerHTML = html; } } window.FirmwareComponent = FirmwareComponent;