698 lines
28 KiB
JavaScript
698 lines
28 KiB
JavaScript
// 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>📤 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>✅ Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}</span>`;
|
||
} else {
|
||
progressHeader.textContent = `📤 Firmware Upload Failed`;
|
||
progressSummary.innerHTML = `<span>❌ 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>✅ 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>⚠️ 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;
|