Files
spore-ui/public/scripts/components/FirmwareComponent.js

1008 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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?<br><br>This will update:<br>${nodes.map(n => `${n.hostname || n.ip}`).join('<br>')}`;
} 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(', ')})?<br><br>This will update:<br>${nodes.map(n => `${n.hostname || n.ip}`).join('<br>')}`;
}
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 = `
<div class="firmware-upload-progress" id="firmware-upload-progress">
<div class="progress-header">
<h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
<path d="M12 16V4"/>
<path d="M8 8l4-4 4 4"/>
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>
</svg>
Firmware Upload Progress
</h3>
<div class="progress-info">
<span>File: ${file.name}</span>
<span>Size: ${(file.size / 1024).toFixed(1)}KB</span>
<span>Targets: ${nodes.length} node(s)</span>
</div>
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar" id="overall-progress-bar" style="width: 0%; background-color: #fbbf24;"></div>
</div>
<span class="progress-text">0/${nodes.length} Successful (0%)</span>
</div>
<div class="progress-summary" id="progress-summary">
<span>Status: Upload in progress...</span>
</div>
</div>
<div class="progress-list" id="progress-list">
${nodes.map(node => `
<div class="progress-item" data-node-ip="${node.ip}">
<div class="progress-node-info">
<span class="node-name">${node.hostname || node.ip}</span>
<span class="node-ip">${node.ip}</span>
</div>
<div class="progress-status uploading">Uploading...</div>
<div class="progress-time" id="time-${node.ip}"></div>
</div>
`).join('')}
</div>
</div>
`;
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 = `<span>${window.icon('success', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
progressHeader.textContent = `Firmware Upload Failed`;
progressSummary.innerHTML = `<span>${window.icon('error', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}</span>`;
}
} else if (successCount === totalCount) {
// Multi-node upload - all successful
progressHeader.textContent = `Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
// Multi-node upload - some failed
progressHeader.textContent = `Firmware Upload Results (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>${window.icon('warning', { width: 14, height: 14 })} Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}</span>`;
}
}
}
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 = `<span>${window.icon('warning', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading, ${failedCount} failed)</span>`;
} else if (uploadingCount > 0) {
progressSummary.innerHTML = `<span>${window.icon('info', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading)</span>`;
} else if (completedCount === totalNodes) {
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
}
}
}
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 = '<option value="">Select a node...</option>';
// 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 = ['<option value="">Select a label...</option>']
.concat(labels.filter(l => !selected.has(l)).map(l => `<option value="${l}">${l}</option>`));
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 => `
<span class="label-chip removable" data-label="${l}">
${l}
<button class="chip-remove" data-label="${l}" title="Remove">×</button>
</span>
`).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 = `<div class="empty-state"><div>No nodes match the selected labels</div></div>`;
return;
}
const html = `
<div class="affected-nodes">
<div class="progress-header"><h3>Affected Nodes (${nodes.length})</h3></div>
<div class="progress-list">
${nodes.map(n => `
<div class="progress-item" data-node-ip="${n.ip}">
<div class="progress-node-info"><span class="node-name">${n.hostname || n.ip}</span><span class="node-ip">${n.ip}</span></div>
</div>
`).join('')}
</div>
</div>`;
container.innerHTML = html;
}
}
window.FirmwareComponent = FirmwareComponent;