diff --git a/public/app.js b/public/app.js index 7fe5b8a..ad3e761 100644 --- a/public/app.js +++ b/public/app.js @@ -21,7 +21,8 @@ document.addEventListener('DOMContentLoaded', function() { // Extract node information for firmware view const nodes = members.map(member => ({ ip: member.ip, - hostname: member.hostname || member.ip + hostname: member.hostname || member.ip, + labels: member.labels || {} })); firmwareViewModel.updateAvailableNodes(nodes); console.log('App: Updated firmware view model with nodes:', nodes); @@ -67,6 +68,33 @@ document.addEventListener('DOMContentLoaded', function() { console.log('=== SPORE UI Application initialization completed ==='); }); +// Burger menu toggle for mobile +(function setupBurgerMenu(){ + document.addEventListener('DOMContentLoaded', function(){ + const nav = document.querySelector('.main-navigation'); + const burger = document.getElementById('burger-btn'); + const navLeft = nav ? nav.querySelector('.nav-left') : null; + if (!nav || !burger || !navLeft) return; + burger.addEventListener('click', function(e){ + e.preventDefault(); + nav.classList.toggle('mobile-open'); + }); + // Close menu when a nav tab is clicked + navLeft.addEventListener('click', function(e){ + const btn = e.target.closest('.nav-tab'); + if (btn && nav.classList.contains('mobile-open')) { + nav.classList.remove('mobile-open'); + } + }); + // Close menu on outside click + document.addEventListener('click', function(e){ + if (!nav.contains(e.target) && nav.classList.contains('mobile-open')) { + nav.classList.remove('mobile-open'); + } + }); + }); +})(); + // Set up periodic updates with state preservation function setupPeriodicUpdates() { // Auto-refresh cluster members every 30 seconds using smart update diff --git a/public/components.js b/public/components.js index 7f077dd..a3fa438 100644 --- a/public/components.js +++ b/public/components.js @@ -1274,6 +1274,25 @@ class FirmwareComponent extends Component { console.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) { @@ -1289,15 +1308,23 @@ class FirmwareComponent extends Component { 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() { @@ -1314,6 +1341,15 @@ class FirmwareComponent extends Component { console.log('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); } + // Initialize target visibility and label list on first mount + try { + this.updateTargetVisibility(); + this.populateLabelSelect(); + this.updateAffectedNodesPreview(); + } catch (e) { + console.warn('FirmwareComponent: Initialization after mount failed:', e); + } + console.log('FirmwareComponent: Mounted successfully'); } @@ -1365,8 +1401,10 @@ class FirmwareComponent extends Component { if (targetType === 'all') { await this.uploadToAllNodes(file); - } else { + } else if (targetType === 'specific') { await this.uploadToSpecificNode(file, specificNode); + } else if (targetType === 'labels') { + await this.uploadToLabelFilteredNodes(file); } // Reset interface after successful upload @@ -1451,6 +1489,31 @@ class FirmwareComponent extends Component { } } + 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) { + console.error('Failed to upload firmware to label-filtered nodes:', error); + throw error; + } + } + async performBatchUpload(file, nodes) { const results = []; const totalNodes = nodes.length; @@ -1671,31 +1734,24 @@ class FirmwareComponent extends Component { updateTargetVisibility() { const targetType = this.viewModel.get('targetType'); const specificNodeSelect = this.findElement('#specific-node-select'); + const labelSelect = this.findElement('#label-select'); console.log('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); if (targetType === 'specific') { - specificNodeSelect.style.visibility = 'visible'; - specificNodeSelect.style.opacity = '1'; - console.log('FirmwareComponent: Showing specific node select'); - - // Check if the dropdown exists and is ready - if (specificNodeSelect && specificNodeSelect.tagName === 'SELECT') { - console.log('FirmwareComponent: Dropdown is ready, populating immediately'); - this.populateNodeSelect(); - } else { - console.log('FirmwareComponent: Dropdown not ready, delaying population'); - // Small delay to ensure the dropdown is visible before populating - setTimeout(() => { - this.populateNodeSelect(); - }, 100); + 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 { - specificNodeSelect.style.visibility = 'hidden'; - specificNodeSelect.style.opacity = '0'; - console.log('FirmwareComponent: Hiding specific node select'); + if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } + if (labelSelect) { labelSelect.style.display = 'none'; } } - this.updateDeployButton(); } @@ -1795,6 +1851,75 @@ class FirmwareComponent extends Component { 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 = [''] + .concat(labels.filter(l => !selected.has(l)).map(l => ``)); + 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 => ` + + ${l} + + + `).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 = `
No nodes match the selected labels
`; + return; + } + const html = ` +
+

🎯 Affected Nodes (${nodes.length})

+
+ ${nodes.map(n => ` +
+
${n.hostname || n.ip}${n.ip}
+
+ `).join('')} +
+
`; + container.innerHTML = html; + } } // Cluster View Component diff --git a/public/index.html b/public/index.html index 254f33b..999a15c 100644 --- a/public/index.html +++ b/public/index.html @@ -9,6 +9,11 @@