// Reusable Firmware Upload Component class FirmwareUploadComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); logger.debug('FirmwareUploadComponent: Constructor called'); logger.debug('FirmwareUploadComponent: Container:', container); logger.debug('FirmwareUploadComponent: Container ID:', container?.id); // Initialize overlay dialog this.overlayDialog = null; } setupEventListeners() { // Setup firmware file input const firmwareFile = this.findElement('#firmware-file'); if (firmwareFile) { this.addEventListener(firmwareFile, 'change', this.handleFileSelect.bind(this)); } // Setup deploy button const deployBtn = this.findElement('#deploy-btn'); if (deployBtn) { this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this)); } // Setup WebSocket listener for real-time firmware upload status this.setupWebSocketListeners(); } setupViewModelListeners() { this.subscribeToProperty('selectedFile', () => { this.updateFileInfo(); this.updateDeployButton(); }); this.subscribeToProperty('isUploading', this.updateUploadState.bind(this)); this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this)); this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); } 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('FirmwareUploadComponent: Firmware upload status received:', { nodeIp, status, filename }); // Check if there's currently an upload in progress const isUploading = this.viewModel.get('isUploading'); if (!isUploading) { logger.debug('FirmwareUploadComponent: No active upload, ignoring status update'); return; } // Find the target node item for this node const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`); if (!targetNodeItem) { logger.debug('FirmwareUploadComponent: No target node item found for node:', nodeIp); return; } // Update the status display based on the received status const statusElement = targetNodeItem.querySelector('.status-indicator'); if (statusElement) { let displayStatus = status; let statusClass = ''; logger.debug(`FirmwareUploadComponent: Updating status for node ${nodeIp}: ${status} -> ${displayStatus}`); switch (status) { case 'uploading': displayStatus = 'Uploading...'; statusClass = 'uploading'; break; case 'completed': displayStatus = 'Completed'; statusClass = 'success'; logger.debug(`FirmwareUploadComponent: Node ${nodeIp} marked as completed`); break; case 'failed': displayStatus = 'Failed'; statusClass = 'error'; break; default: displayStatus = status; break; } statusElement.textContent = displayStatus; statusElement.className = `status-indicator ${statusClass}`; } // Update overall progress if we have multiple nodes this.updateOverallProgressFromStatus(); // Check if all uploads are complete and finalize results this.checkAndFinalizeUploadResults(); } updateOverallProgressFromStatus() { const targetNodeItems = Array.from(this.findAllElements('.target-node-item')); if (targetNodeItems.length <= 1) { return; // Only update for multi-node uploads } let completedCount = 0; let failedCount = 0; let uploadingCount = 0; targetNodeItems.forEach(item => { const statusElement = item.querySelector('.status-indicator'); if (statusElement) { const status = statusElement.textContent; if (status === 'Completed') { completedCount++; } else if (status === 'Failed') { failedCount++; } else if (status === 'Uploading...') { uploadingCount++; } } }); const totalNodes = targetNodeItems.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()}`; } } } checkAndFinalizeUploadResults() { const targetNodeItems = Array.from(this.findAllElements('.target-node-item')); if (targetNodeItems.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 = []; targetNodeItems.forEach(item => { const statusElement = item.querySelector('.status-indicator'); 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('FirmwareUploadComponent: Upload status check:', { totalItems: targetNodeItems.length, allComplete, uploadingCount, hasAnyCompleted, hasAnyFailed, statuses }); // If all uploads are complete, finalize the results if (allComplete) { logger.debug('FirmwareUploadComponent: All firmware uploads complete, finalizing results'); // Generate results based on current status const results = targetNodeItems.map(item => { const nodeIp = item.getAttribute('data-node-ip'); const nodeName = item.querySelector('.node-name')?.textContent || nodeIp; const statusElement = item.querySelector('.status-indicator'); 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); // Hide the progress overlay since upload is complete this.hideProgressOverlay(); // 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 and re-enable deploy button setTimeout(() => { this.viewModel.resetUploadState(); logger.debug('FirmwareUploadComponent: Upload state reset, deploy button should be re-enabled'); }, 5000); } else if (uploadingCount > 0) { logger.debug(`FirmwareUploadComponent: ${uploadingCount} uploads still in progress, not finalizing yet`); } else { logger.debug('FirmwareUploadComponent: Some uploads may have unknown status, but not finalizing yet'); } } mount() { super.mount(); logger.debug('FirmwareUploadComponent: Mounting...'); // Initialize overlay dialog this.initializeOverlayDialog(); // Initialize UI state this.updateFileInfo(); this.updateDeployButton(); logger.debug('FirmwareUploadComponent: 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-upload-overlay-dialog'); if (!overlayContainer) { overlayContainer = document.createElement('div'); overlayContainer.id = 'firmware-upload-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(); } } showConfirmationDialog(options) { if (!this.overlayDialog) { this.initializeOverlayDialog(); } this.overlayDialog.show(options); } handleFileSelect(event) { const file = event.target.files[0]; this.viewModel.setSelectedFile(file); } async handleDeploy() { const file = this.viewModel.get('selectedFile'); const targetNodes = this.viewModel.get('targetNodes'); if (!file) { this.showConfirmationDialog({ title: 'No File Selected', message: 'Please select a firmware file first.', confirmText: 'OK', cancelText: null, onConfirm: () => {}, onCancel: null }); return; } if (!targetNodes || targetNodes.length === 0) { this.showConfirmationDialog({ title: 'No Target Nodes', message: 'No target nodes available for firmware update.', confirmText: 'OK', cancelText: null, onConfirm: () => {}, onCancel: null }); return; } // Show confirmation dialog for deployment this.showDeploymentConfirmation(file, targetNodes); } showDeploymentConfirmation(file, targetNodes) { const title = 'Deploy Firmware'; const message = `Upload firmware "${file.name}" to ${targetNodes.length} node(s)?

Target nodes:
${targetNodes.map(n => `• ${n.hostname || n.ip} (${n.ip})`).join('
')}

This will update the firmware on all selected nodes.`; this.showConfirmationDialog({ title: title, message: message, confirmText: 'Deploy', cancelText: 'Cancel', onConfirm: () => this.performDeployment(file, targetNodes), onCancel: () => {} }); } async performDeployment(file, targetNodes) { try { this.viewModel.startUpload(); // Show progress overlay to block UI interactions this.showProgressOverlay(); // Show upload progress area this.showUploadProgress(file, targetNodes); // Start batch upload const results = await this.performBatchUpload(file, targetNodes); // NOTE: Don't display results or reset state here! // The upload state should remain active until websocket confirms completion // Status updates and finalization happen via websocket messages in checkAndFinalizeUploadResults() logger.debug('FirmwareUploadComponent: Firmware upload HTTP requests completed, waiting for websocket status updates'); } catch (error) { logger.error('FirmwareUploadComponent: 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(); this.hideProgressOverlay(); } } 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(`FirmwareUploadComponent: Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); results.push(result); } catch (error) { logger.error(`FirmwareUploadComponent: 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(`FirmwareUploadComponent: HTTP acknowledgment received for ${nodeIp}:`, result); logger.debug(`FirmwareUploadComponent: 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) { // Update the target nodes section header to show upload progress const targetNodesSection = this.findElement('.target-nodes-section'); if (targetNodesSection) { const h3 = targetNodesSection.querySelector('h3'); if (h3) { h3.innerHTML = ` Firmware Upload Progress (${nodes.length} nodes) `; } } // Add progress info to the firmware-progress-container const container = this.findElement('#firmware-progress-container'); if (container) { const progressHTML = `
0/${nodes.length} Successful (0%)
Status: Upload in progress...
`; container.innerHTML = progressHTML; } // Update existing target nodes to show upload status this.updateTargetNodesForUpload(nodes); } updateTargetNodesForUpload(nodes) { const targetNodesList = this.findElement('.target-nodes-list'); if (!targetNodesList) return; // Update each target node item to show upload status targetNodesList.innerHTML = nodes.map(node => `
${node.hostname || node.ip} ${node.ip}
Uploading...
`).join(''); } updateNodeProgress(current, total, nodeIp, status) { const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`); if (targetNodeItem) { const statusElement = targetNodeItem.querySelector('.status-indicator'); if (statusElement) { statusElement.textContent = status; // Update status-specific styling statusElement.className = 'status-indicator'; if (status === 'Completed') { statusElement.classList.add('success'); } else if (status === 'Failed') { statusElement.classList.add('error'); } else if (status === 'Uploading...') { statusElement.classList.add('uploading'); } else if (status === 'Pending...') { statusElement.classList.add('pending'); } } } } 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'); 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(); } updateDeployButton() { const deployBtn = this.findElement('#deploy-btn'); if (deployBtn) { const file = this.viewModel.get('selectedFile'); const targetNodes = this.viewModel.get('targetNodes'); const isUploading = this.viewModel.get('isUploading'); deployBtn.disabled = !file || !targetNodes || targetNodes.length === 0 || isUploading; } } updateUploadState() { const isUploading = this.viewModel.get('isUploading'); const deployBtn = this.findElement('#deploy-btn'); if (deployBtn) { deployBtn.disabled = isUploading; if (isUploading) { deployBtn.classList.add('loading'); // Update button text while keeping the SVG icon const iconSvg = deployBtn.querySelector('svg'); deployBtn.innerHTML = ''; if (iconSvg) { deployBtn.appendChild(iconSvg); } deployBtn.appendChild(document.createTextNode(' Deploying...')); } else { deployBtn.classList.remove('loading'); // Restore original button content with SVG icon deployBtn.innerHTML = ` Deploy `; } } } updateUploadProgress() { // This will be implemented when we add upload progress tracking } updateUploadResults() { // This will be implemented when we add upload results display } showProgressOverlay() { // Create overlay element that only covers the left side (main content area) const overlay = document.createElement('div'); overlay.id = 'firmware-upload-overlay'; overlay.className = 'firmware-upload-overlay'; overlay.innerHTML = `
Firmware upload in progress...
Check the drawer for detailed progress
`; // Add to body document.body.appendChild(overlay); // Check if drawer is open and adjust overlay accordingly const drawer = document.querySelector('.details-drawer'); if (drawer && drawer.classList.contains('open')) { overlay.classList.add('drawer-open'); } // Block ESC key during upload this.blockEscapeKey(); // Block drawer close button during upload this.blockDrawerCloseButton(); // Block choose file button during upload this.blockChooseFileButton(); // Store reference for cleanup this.progressOverlay = overlay; } blockDrawerCloseButton() { // Find the drawer close button const closeButton = document.querySelector('.drawer-close'); if (closeButton) { // Store original state this.originalCloseButtonDisabled = closeButton.disabled; this.originalCloseButtonStyle = closeButton.style.cssText; // Disable the close button closeButton.disabled = true; closeButton.style.opacity = '0.5'; closeButton.style.cursor = 'not-allowed'; closeButton.style.pointerEvents = 'none'; // Add visual indicator that it's disabled closeButton.title = 'Cannot close during firmware upload'; } } unblockDrawerCloseButton() { // Restore the drawer close button const closeButton = document.querySelector('.drawer-close'); if (closeButton) { // Restore original state closeButton.disabled = this.originalCloseButtonDisabled || false; closeButton.style.cssText = this.originalCloseButtonStyle || ''; closeButton.title = 'Close'; } } blockChooseFileButton() { // Find the choose file button const chooseFileButton = document.querySelector('.upload-btn-compact'); if (chooseFileButton) { // Store original state this.originalChooseFileButtonDisabled = chooseFileButton.disabled; this.originalChooseFileButtonStyle = chooseFileButton.style.cssText; // Disable the choose file button chooseFileButton.disabled = true; chooseFileButton.style.opacity = '0.5'; chooseFileButton.style.cursor = 'not-allowed'; chooseFileButton.style.pointerEvents = 'none'; // Add visual indicator that it's disabled chooseFileButton.title = 'Cannot change file during upload'; } } unblockChooseFileButton() { // Restore the choose file button const chooseFileButton = document.querySelector('.upload-btn-compact'); if (chooseFileButton) { // Restore original state chooseFileButton.disabled = this.originalChooseFileButtonDisabled || false; chooseFileButton.style.cssText = this.originalChooseFileButtonStyle || ''; chooseFileButton.title = 'Choose File'; } } hideTargetNodesSection() { // Find the target nodes section const targetNodesSection = document.querySelector('.target-nodes-section'); if (targetNodesSection) { // Store original state this.originalTargetNodesSectionDisplay = targetNodesSection.style.display; // Hide the target nodes section targetNodesSection.style.display = 'none'; } } showTargetNodesSection() { // Restore the target nodes section const targetNodesSection = document.querySelector('.target-nodes-section'); if (targetNodesSection) { // Restore original state targetNodesSection.style.display = this.originalTargetNodesSectionDisplay || ''; } } blockEscapeKey() { // Create a keydown event listener that prevents ESC from closing the drawer this.escapeKeyHandler = (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); return false; } }; // Add the event listener with capture=true to intercept before drawer's handler document.addEventListener('keydown', this.escapeKeyHandler, true); } unblockEscapeKey() { // Remove the ESC key blocker if (this.escapeKeyHandler) { document.removeEventListener('keydown', this.escapeKeyHandler, true); this.escapeKeyHandler = null; } } hideProgressOverlay() { if (this.progressOverlay) { this.progressOverlay.remove(); this.progressOverlay = null; } // Unblock ESC key this.unblockEscapeKey(); // Unblock drawer close button this.unblockDrawerCloseButton(); // Unblock choose file button this.unblockChooseFileButton(); } } window.FirmwareUploadComponent = FirmwareUploadComponent;