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

705 lines
29 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);
// 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 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...');
// 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();
}
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) {
alert('Please select a firmware file first.');
return;
}
if (targetType === 'specific' && !specificNode) {
alert('Please select a specific node to update.');
return;
}
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);
}
// Reset interface after successful upload
this.viewModel.resetUploadState();
} catch (error) {
logger.error('Firmware deployment failed:', error);
alert(`Deployment failed: ${error.message}`);
} finally {
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) {
alert('No nodes available for firmware update.');
return;
}
const confirmed = confirm(`Upload firmware to all ${nodes.length} nodes? This will update: ${nodes.map(n => n.hostname || n.ip).join(', ')}`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, nodes);
// Start batch upload
const results = await this.performBatchUpload(file, nodes);
// Display results
this.displayUploadResults(results);
} catch (error) {
logger.error('Failed to upload firmware to all nodes:', error);
throw error;
}
}
async uploadToSpecificNode(file, nodeIp) {
try {
const confirmed = confirm(`Upload firmware to node ${nodeIp}?`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]);
// Update progress to show starting
this.updateNodeProgress(1, 1, nodeIp, 'Uploading...');
// Perform single node upload
const result = await this.performSingleUpload(file, nodeIp);
// Update progress to show completion
this.updateNodeProgress(1, 1, nodeIp, 'Completed');
this.updateOverallProgress(1, 1);
// Display results
this.displayUploadResults([result]);
} catch (error) {
logger.error(`Failed to upload firmware to node ${nodeIp}:`, error);
// Update progress to show failure
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) {
alert('No nodes match the selected labels.');
return;
}
const labels = this.viewModel.get('selectedLabels') || [];
const confirmed = confirm(`Upload firmware to ${nodes.length} node(s) matching labels (${labels.join(', ')})?`);
if (!confirmed) return;
// Show upload progress area
this.showUploadProgress(file, nodes);
// Start batch upload
const results = await this.performBatchUpload(file, nodes);
// Display results
this.displayUploadResults(results);
} 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;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const nodeIp = node.ip;
try {
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...');
// Upload to this node
const result = await this.performSingleUpload(file, nodeIp);
results.push(result);
successfulUploads++;
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed');
this.updateOverallProgress(successfulUploads, totalNodes);
} 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);
// Update progress
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed');
this.updateOverallProgress(successfulUploads, totalNodes);
}
// 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);
return {
nodeIp: nodeIp,
hostname: nodeIp,
success: true,
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: Preparing upload...</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">Pending...</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, 'Pending...');
}
}
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';
}
// Update progress summary for single-node uploads
const progressSummary = this.findElement('#progress-summary');
if (progressSummary && totalNodes === 1) {
if (successfulUploads === 1) {
progressSummary.innerHTML = '<span>Status: Upload completed successfully</span>';
} else if (successfulUploads === 0) {
progressSummary.innerHTML = '<span>Status: Upload failed</span>';
}
}
}
}
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();
}
}
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;