refactor(components): split components.js into separate files and add loader; app waits for components before init

This commit is contained in:
2025-08-31 14:00:33 +02:00
parent 83d252f3cc
commit cc7fa0fa00
11 changed files with 3124 additions and 2 deletions

View File

@@ -0,0 +1,698 @@
// 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 and 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 and 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 and 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;