From 478d23b805adb1d531e70efefa17fc764f958b0d Mon Sep 17 00:00:00 2001 From: 0x1d Date: Thu, 16 Oct 2025 21:26:16 +0200 Subject: [PATCH 1/2] feat: firmware upload on the cluster view --- public/index.html | 27 +- public/scripts/app.js | 25 +- .../components/ClusterMembersComponent.js | 5 + .../components/ClusterViewComponent.js | 130 ++++ public/scripts/components/DrawerComponent.js | 8 +- .../components/FirmwareUploadComponent.js | 539 +++++++++++++++ public/scripts/view-models.js | 73 ++ public/styles/main.css | 652 +++++++++++++++++- 8 files changed, 1446 insertions(+), 13 deletions(-) create mode 100644 public/scripts/components/FirmwareUploadComponent.js diff --git a/public/index.html b/public/index.html index a1d3cbc..faa8101 100644 --- a/public/index.html +++ b/public/index.html @@ -102,14 +102,24 @@ - +
+ + +
@@ -254,6 +264,7 @@ + diff --git a/public/scripts/app.js b/public/scripts/app.js index 40b5379..4a25751 100644 --- a/public/scripts/app.js +++ b/public/scripts/app.js @@ -14,9 +14,10 @@ document.addEventListener('DOMContentLoaded', async function() { logger.debug('App: Creating view models...'); const clusterViewModel = new ClusterViewModel(); const firmwareViewModel = new FirmwareViewModel(); + const clusterFirmwareViewModel = new ClusterFirmwareViewModel(); const topologyViewModel = new TopologyViewModel(); const monitoringViewModel = new MonitoringViewModel(); - logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel, monitoringViewModel }); + logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel }); // Connect firmware view model to cluster data clusterViewModel.subscribe('members', (members) => { @@ -36,6 +37,28 @@ document.addEventListener('DOMContentLoaded', async function() { } }); + // Connect cluster firmware view model to cluster data + // Note: This subscription is disabled because target nodes should be set explicitly + // when opening the firmware deploy drawer, not automatically updated + /* + clusterViewModel.subscribe('members', (members) => { + logger.debug('App: Members subscription triggered for cluster firmware:', members); + if (members && members.length > 0) { + // Extract node information for cluster firmware view + const nodes = members.map(member => ({ + ip: member.ip, + hostname: member.hostname || member.ip, + labels: member.labels || {} + })); + clusterFirmwareViewModel.setTargetNodes(nodes); + logger.debug('App: Updated cluster firmware view model with nodes:', nodes); + } else { + clusterFirmwareViewModel.setTargetNodes([]); + logger.debug('App: Cleared cluster firmware view model nodes'); + } + }); + */ + // Register routes with their view models logger.debug('App: Registering routes...'); app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel); diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js index 97a0410..32137b5 100644 --- a/public/scripts/components/ClusterMembersComponent.js +++ b/public/scripts/components/ClusterMembersComponent.js @@ -95,6 +95,11 @@ class ClusterMembersComponent extends Component { }); } + // Get currently filtered members (public method for external access) + getFilteredMembers() { + return this.filterMembers(this.allMembers); + } + // Update filter dropdowns with current label data updateFilterDropdowns() { // Get currently filtered members to determine available options diff --git a/public/scripts/components/ClusterViewComponent.js b/public/scripts/components/ClusterViewComponent.js index f341e2e..7f200b2 100644 --- a/public/scripts/components/ClusterViewComponent.js +++ b/public/scripts/components/ClusterViewComponent.js @@ -47,6 +47,9 @@ class ClusterViewComponent extends Component { // Set up refresh button event listener (since it's in the cluster header, not in the members container) this.setupRefreshButton(); + // Set up deploy button event listener + this.setupDeployButton(); + // Only load data if we haven't already or if the view model is empty const members = this.viewModel.get('members'); const shouldLoadData = true; // always perform initial refresh quickly @@ -86,6 +89,133 @@ class ClusterViewComponent extends Component { } } + setupDeployButton() { + logger.debug('ClusterViewComponent: Setting up deploy button...'); + + const deployBtn = this.findElement('#deploy-firmware-btn'); + logger.debug('ClusterViewComponent: Found deploy button:', !!deployBtn, deployBtn); + + if (deployBtn) { + logger.debug('ClusterViewComponent: Adding click event listener to deploy button'); + this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this)); + logger.debug('ClusterViewComponent: Event listener added successfully'); + } else { + logger.error('ClusterViewComponent: Deploy button not found!'); + logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML); + logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button')); + } + } + + async handleDeploy() { + logger.debug('ClusterViewComponent: Deploy button clicked, opening firmware upload drawer...'); + + // Get current filtered members from cluster members component + const filteredMembers = this.clusterMembersComponent ? this.clusterMembersComponent.getFilteredMembers() : []; + + if (!filteredMembers || filteredMembers.length === 0) { + alert('No nodes available for firmware deployment. Please ensure cluster members are loaded and visible.'); + return; + } + + // Open drawer with firmware upload interface + this.openFirmwareUploadDrawer(filteredMembers); + } + + openFirmwareUploadDrawer(targetNodes) { + logger.debug('ClusterViewComponent: Opening firmware upload drawer for', targetNodes.length, 'nodes'); + + // Get display name for drawer title + const nodeCount = targetNodes.length; + const displayName = `Firmware Deployment - ${nodeCount} node${nodeCount !== 1 ? 's' : ''}`; + + // Open drawer with content callback (hide terminal button for firmware upload) + this.clusterMembersComponent.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => { + // Create firmware upload view model and component + const firmwareUploadVM = new ClusterFirmwareViewModel(); + firmwareUploadVM.setTargetNodes(targetNodes); + + // Create HTML for firmware upload interface + contentContainer.innerHTML = ` +
+
+ + +
+
+
+ + + No file selected +
+ +
+
+ +
+

+ + + + + + + Target Nodes (${targetNodes.length}) +

+
+ ${targetNodes.map(node => ` +
+
+ ${node.hostname || node.ip} + ${node.ip} +
+
+ Ready +
+
+ `).join('')} +
+
+ + +
+ +
+
+
+ `; + + // Create and mount firmware upload component + const firmwareUploadComponent = new FirmwareUploadComponent(contentContainer, firmwareUploadVM, this.eventBus); + setActiveComponent(firmwareUploadComponent); + firmwareUploadComponent.mount(); + + }, null, () => { + // Close callback - clear any upload state + logger.debug('ClusterViewComponent: Firmware upload drawer closed'); + }, true); // Hide terminal button for firmware upload + } + async handleRefresh() { logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...'); diff --git a/public/scripts/components/DrawerComponent.js b/public/scripts/components/DrawerComponent.js index e0d171e..370565f 100644 --- a/public/scripts/components/DrawerComponent.js +++ b/public/scripts/components/DrawerComponent.js @@ -91,7 +91,7 @@ class DrawerComponent { }); } - openDrawer(title, contentCallback, errorCallback, onCloseCallback) { + openDrawer(title, contentCallback, errorCallback, onCloseCallback, hideTerminalButton = false) { this.ensureDrawer(); this.onCloseCallback = onCloseCallback; @@ -101,6 +101,12 @@ class DrawerComponent { titleEl.textContent = title; } + // Show/hide terminal button based on parameter + const terminalBtn = this.detailsDrawer.querySelector('.drawer-terminal-btn'); + if (terminalBtn) { + terminalBtn.style.display = hideTerminalButton ? 'none' : 'block'; + } + // Clear previous component if any if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') { try { diff --git a/public/scripts/components/FirmwareUploadComponent.js b/public/scripts/components/FirmwareUploadComponent.js new file mode 100644 index 0000000..8aed430 --- /dev/null +++ b/public/scripts/components/FirmwareUploadComponent.js @@ -0,0 +1,539 @@ +// Reusable Firmware Upload Component +class FirmwareUploadComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('FirmwareUploadComponent: Constructor called'); + logger.debug('FirmwareUploadComponent: Container:', container); + logger.debug('FirmwareUploadComponent: Container ID:', container?.id); + } + + setupEventListeners() { + // Setup firmware file input + const firmwareFile = this.findElement('#firmware-file'); + if (firmwareFile) { + this.addEventListener(firmwareFile, 'change', this.handleFileSelect.bind(this)); + } + + // 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('isUploading', this.updateUploadState.bind(this)); + this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this)); + this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); + } + + mount() { + super.mount(); + + logger.debug('FirmwareUploadComponent: Mounting...'); + + // Initialize UI state + this.updateFileInfo(); + this.updateDeployButton(); + + logger.debug('FirmwareUploadComponent: 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); + } + + async handleDeploy() { + const file = this.viewModel.get('selectedFile'); + const targetNodes = this.viewModel.get('targetNodes'); + + if (!file) { + alert('Please select a firmware file first.'); + return; + } + + if (!targetNodes || targetNodes.length === 0) { + alert('No target nodes available for firmware update.'); + return; + } + + try { + this.viewModel.startUpload(); + + // Show progress overlay to block UI interactions + this.showProgressOverlay(); + + const confirmed = confirm(`Upload firmware to ${targetNodes.length} node(s)?\n\nTarget nodes:\n${targetNodes.map(n => `• ${n.hostname || n.ip} (${n.ip})`).join('\n')}\n\nThis will update the firmware on all selected nodes.`); + if (!confirmed) { + this.viewModel.completeUpload(); + this.hideProgressOverlay(); + return; + } + + // Show upload progress area + this.showUploadProgress(file, targetNodes); + + // Start batch upload + const results = await this.performBatchUpload(file, targetNodes); + + // Display results + this.displayUploadResults(results); + + // 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(); + this.hideProgressOverlay(); + } + } + + 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) { + // Update the target nodes section header to show upload progress + const targetNodesSection = this.findElement('.target-nodes-section'); + if (targetNodesSection) { + const h3 = targetNodesSection.querySelector('h3'); + if (h3) { + h3.innerHTML = ` + + + + + + Firmware Upload Progress (${nodes.length} nodes) + `; + } + } + + // Add progress info to the firmware-progress-container + const container = this.findElement('#firmware-progress-container'); + if (container) { + const progressHTML = ` +
+
+
+
+
+ 0/${nodes.length} Successful (0%) +
+
+ Status: Preparing upload... +
+
+ `; + container.innerHTML = progressHTML; + } + + // Update existing target nodes to show upload status + this.updateTargetNodesForUpload(nodes); + } + + updateTargetNodesForUpload(nodes) { + const targetNodesList = this.findElement('.target-nodes-list'); + if (!targetNodesList) return; + + // Update each target node item to show upload status + targetNodesList.innerHTML = nodes.map(node => ` +
+
+ ${node.hostname || node.ip} + ${node.ip} +
+
+ Pending... +
+
+ `).join(''); + } + + updateNodeProgress(current, total, nodeIp, status) { + const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`); + if (targetNodeItem) { + const statusElement = targetNodeItem.querySelector('.status-indicator'); + + if (statusElement) { + statusElement.textContent = status; + + // Update status-specific styling + statusElement.className = 'status-indicator'; + if (status === 'Completed') { + statusElement.classList.add('success'); + } else if (status === 'Failed') { + statusElement.classList.add('error'); + } else if (status === 'Uploading...') { + statusElement.classList.add('uploading'); + } else if (status === 'Pending...') { + statusElement.classList.add('pending'); + } + } + } + } + + 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 = 'Status: Upload completed successfully'; + } else if (successfulUploads === 0) { + progressSummary.innerHTML = 'Status: Upload failed'; + } + } + } + } + + 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 = `${window.icon('success', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}`; + } else { + progressHeader.textContent = `Firmware Upload Failed`; + progressSummary.innerHTML = `${window.icon('error', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}`; + } + } else if (successCount === totalCount) { + // Multi-node upload - all successful + progressHeader.textContent = `Firmware Upload Complete (${successCount}/${totalCount} Successful)`; + progressSummary.innerHTML = `${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}`; + } else { + // Multi-node upload - some failed + progressHeader.textContent = `Firmware Upload Results (${successCount}/${totalCount} Successful)`; + progressSummary.innerHTML = `${window.icon('warning', { width: 14, height: 14 })} Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}`; + } + } + } + + updateFileInfo() { + const file = this.viewModel.get('selectedFile'); + const fileInfo = this.findElement('#file-info'); + + 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(); + } + + updateDeployButton() { + const deployBtn = this.findElement('#deploy-btn'); + if (deployBtn) { + const file = this.viewModel.get('selectedFile'); + const targetNodes = this.viewModel.get('targetNodes'); + const isUploading = this.viewModel.get('isUploading'); + + deployBtn.disabled = !file || !targetNodes || targetNodes.length === 0 || isUploading; + } + } + + updateUploadState() { + const isUploading = this.viewModel.get('isUploading'); + const deployBtn = this.findElement('#deploy-btn'); + + if (deployBtn) { + deployBtn.disabled = isUploading; + if (isUploading) { + deployBtn.classList.add('loading'); + // Update button text while keeping the SVG icon + const iconSvg = deployBtn.querySelector('svg'); + deployBtn.innerHTML = ''; + if (iconSvg) { + deployBtn.appendChild(iconSvg); + } + deployBtn.appendChild(document.createTextNode(' Deploying...')); + } else { + deployBtn.classList.remove('loading'); + // Restore original button content with SVG icon + deployBtn.innerHTML = ` + + + + + + Deploy + `; + } + } + + this.updateDeployButton(); + } + + updateUploadProgress() { + // This will be implemented when we add upload progress tracking + } + + updateUploadResults() { + // This will be implemented when we add upload results display + } + + showProgressOverlay() { + // Create overlay element that only covers the left side (main content area) + const overlay = document.createElement('div'); + overlay.id = 'firmware-upload-overlay'; + overlay.className = 'firmware-upload-overlay'; + overlay.innerHTML = ` +
+
+ + + + +
+
Firmware upload in progress...
+
Check the drawer for detailed progress
+
+ `; + + // Add to body + document.body.appendChild(overlay); + + // Check if drawer is open and adjust overlay accordingly + const drawer = document.querySelector('.details-drawer'); + if (drawer && drawer.classList.contains('open')) { + overlay.classList.add('drawer-open'); + } + + // Block ESC key during upload + this.blockEscapeKey(); + + // Block drawer close button during upload + this.blockDrawerCloseButton(); + + // Block choose file button during upload + this.blockChooseFileButton(); + + + // Store reference for cleanup + this.progressOverlay = overlay; + } + + blockDrawerCloseButton() { + // Find the drawer close button + const closeButton = document.querySelector('.drawer-close'); + if (closeButton) { + // Store original state + this.originalCloseButtonDisabled = closeButton.disabled; + this.originalCloseButtonStyle = closeButton.style.cssText; + + // Disable the close button + closeButton.disabled = true; + closeButton.style.opacity = '0.5'; + closeButton.style.cursor = 'not-allowed'; + closeButton.style.pointerEvents = 'none'; + + // Add visual indicator that it's disabled + closeButton.title = 'Cannot close during firmware upload'; + } + } + + unblockDrawerCloseButton() { + // Restore the drawer close button + const closeButton = document.querySelector('.drawer-close'); + if (closeButton) { + // Restore original state + closeButton.disabled = this.originalCloseButtonDisabled || false; + closeButton.style.cssText = this.originalCloseButtonStyle || ''; + closeButton.title = 'Close'; + } + } + + blockChooseFileButton() { + // Find the choose file button + const chooseFileButton = document.querySelector('.upload-btn-compact'); + if (chooseFileButton) { + // Store original state + this.originalChooseFileButtonDisabled = chooseFileButton.disabled; + this.originalChooseFileButtonStyle = chooseFileButton.style.cssText; + + // Disable the choose file button + chooseFileButton.disabled = true; + chooseFileButton.style.opacity = '0.5'; + chooseFileButton.style.cursor = 'not-allowed'; + chooseFileButton.style.pointerEvents = 'none'; + + // Add visual indicator that it's disabled + chooseFileButton.title = 'Cannot change file during upload'; + } + } + + unblockChooseFileButton() { + // Restore the choose file button + const chooseFileButton = document.querySelector('.upload-btn-compact'); + if (chooseFileButton) { + // Restore original state + chooseFileButton.disabled = this.originalChooseFileButtonDisabled || false; + chooseFileButton.style.cssText = this.originalChooseFileButtonStyle || ''; + chooseFileButton.title = 'Choose File'; + } + } + + hideTargetNodesSection() { + // Find the target nodes section + const targetNodesSection = document.querySelector('.target-nodes-section'); + if (targetNodesSection) { + // Store original state + this.originalTargetNodesSectionDisplay = targetNodesSection.style.display; + + // Hide the target nodes section + targetNodesSection.style.display = 'none'; + } + } + + showTargetNodesSection() { + // Restore the target nodes section + const targetNodesSection = document.querySelector('.target-nodes-section'); + if (targetNodesSection) { + // Restore original state + targetNodesSection.style.display = this.originalTargetNodesSectionDisplay || ''; + } + } + + blockEscapeKey() { + // Create a keydown event listener that prevents ESC from closing the drawer + this.escapeKeyHandler = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + return false; + } + }; + + // Add the event listener with capture=true to intercept before drawer's handler + document.addEventListener('keydown', this.escapeKeyHandler, true); + } + + unblockEscapeKey() { + // Remove the ESC key blocker + if (this.escapeKeyHandler) { + document.removeEventListener('keydown', this.escapeKeyHandler, true); + this.escapeKeyHandler = null; + } + } + + hideProgressOverlay() { + if (this.progressOverlay) { + this.progressOverlay.remove(); + this.progressOverlay = null; + } + + // Unblock ESC key + this.unblockEscapeKey(); + + // Unblock drawer close button + this.unblockDrawerCloseButton(); + + // Unblock choose file button + this.unblockChooseFileButton(); + + } +} + +window.FirmwareUploadComponent = FirmwareUploadComponent; diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index 56d1ccd..1e64722 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -958,4 +958,77 @@ class MonitoringViewModel extends ViewModel { async refresh() { await this.loadClusterData(); } +} + +// Cluster Firmware Upload View Model +class ClusterFirmwareViewModel extends ViewModel { + constructor() { + super(); + this.setMultiple({ + selectedFile: null, + targetNodes: [], + uploadProgress: null, + uploadResults: [], + isUploading: false + }); + } + + // Set selected file + setSelectedFile(file) { + this.set('selectedFile', file); + } + + // Set target nodes (filtered from cluster view) + setTargetNodes(nodes) { + this.set('targetNodes', nodes); + } + + // Start upload + startUpload() { + this.set('isUploading', true); + this.set('uploadProgress', { + current: 0, + total: 0, + status: 'Preparing...' + }); + this.set('uploadResults', []); + } + + // Update upload progress + updateUploadProgress(current, total, status) { + this.set('uploadProgress', { + current, + total, + status + }); + } + + // Add upload result + addUploadResult(result) { + const results = this.get('uploadResults'); + results.push(result); + this.set('uploadResults', results); + } + + // Complete upload + completeUpload() { + this.set('isUploading', false); + } + + // Reset upload state + resetUploadState() { + this.set('selectedFile', null); + this.set('uploadProgress', null); + this.set('uploadResults', []); + this.set('isUploading', false); + } + + // Check if deploy is enabled + isDeployEnabled() { + const file = this.get('selectedFile'); + const targetNodes = this.get('targetNodes'); + const isUploading = this.get('isUploading'); + + return file && targetNodes && targetNodes.length > 0 && !isUploading; + } } \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index 8097c95..0844a57 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -401,6 +401,52 @@ p { } } +/* Deploy Button */ +.deploy-btn { + background: linear-gradient(135deg, rgba(74, 222, 128, 0.2) 0%, rgba(74, 222, 128, 0.1) 100%); + border: 1px solid rgba(74, 222, 128, 0.3); + color: var(--accent-primary); + padding: 0.75rem 1.25rem; + border-radius: 12px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.deploy-btn:hover { + background: linear-gradient(135deg, rgba(74, 222, 128, 0.3) 0%, rgba(74, 222, 128, 0.15) 100%); + border-color: rgba(74, 222, 128, 0.4); + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(74, 222, 128, 0.2); +} + +.deploy-btn:active { + transform: translateY(0); +} + +.deploy-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.deploy-btn.loading { + opacity: 0.8; + cursor: not-allowed; +} + +/* Cluster Header Right */ +.cluster-header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + .members-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); @@ -3588,9 +3634,609 @@ select.param-input:focus { overflow: auto; } +/* Firmware Upload Drawer Styles */ +.firmware-upload-drawer { + display: flex; + flex-direction: column; + height: 100%; +} + +.firmware-upload-section { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.firmware-upload-header h3 { + display: flex; + align-items: center; + margin: 0 0 1rem 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +.firmware-upload-controls { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: 12px; + border: 1px solid var(--border-secondary); +} + +.file-input-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.file-input-left { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.upload-btn-compact { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.upload-btn-compact:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +/* Compact Deploy Button */ +.deploy-btn-compact { + background: linear-gradient(135deg, rgba(74, 222, 128, 0.2) 0%, rgba(74, 222, 128, 0.1) 100%); + border: 1px solid rgba(74, 222, 128, 0.3); + color: var(--accent-primary); + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.deploy-btn-compact:hover { + background: linear-gradient(135deg, rgba(74, 222, 128, 0.3) 0%, rgba(74, 222, 128, 0.15) 100%); + border-color: rgba(74, 222, 128, 0.4); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(74, 222, 128, 0.2); +} + +.deploy-btn-compact:active { + transform: translateY(0); +} + +.deploy-btn-compact:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.deploy-btn-compact.loading { + opacity: 0.8; + cursor: not-allowed; +} + +.file-info { + font-size: 0.9rem; + color: var(--text-tertiary); + font-style: italic; +} + +.file-info.has-file { + color: var(--text-secondary); + font-style: normal; + font-weight: 500; +} + +.target-nodes-section { + padding: 1rem; + background: var(--bg-secondary); + border-radius: 12px; + border: 1px solid var(--border-secondary); +} + +.target-nodes-section h3 { + margin: 0 0 1.5rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.target-nodes-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.target-node-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0.7rem; + background: var(--bg-primary); + border-radius: 12px; + border: 1px solid var(--border-primary); + gap: 1.5rem; +} + +.target-node-item .node-info { + display: flex; + flex-direction: column; + gap: 0.2rem; + flex: 1; + min-width: 0; +} + +.target-node-item .node-info .node-name { + font-weight: 600; + color: var(--text-primary); + font-size: 1rem; + line-height: 1.2; +} + +.target-node-item .node-info .node-ip { + font-size: 0.85rem; + color: var(--text-tertiary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + line-height: 1.2; +} + +.target-node-item .node-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + min-width: 140px; + text-align: right; +} + +.status-indicator { + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 100px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.status-indicator.ready { + background: rgba(76, 175, 80, 0.2); + color: var(--accent-success); + border: 1px solid rgba(76, 175, 80, 0.3); +} + +.status-indicator.pending { + background: rgba(255, 152, 0, 0.2); + color: #ff9800; + border: 1px solid rgba(255, 152, 0, 0.3); +} + +.status-indicator.uploading { + background: rgba(33, 150, 243, 0.2); + color: #2196f3; + border: 1px solid rgba(33, 150, 243, 0.3); +} + +.status-indicator.success { + background: rgba(76, 175, 80, 0.2); + color: var(--accent-success); + border: 1px solid rgba(76, 175, 80, 0.3); +} + +.status-indicator.error { + background: rgba(244, 67, 54, 0.2); + color: #f44336; + border: 1px solid rgba(244, 67, 54, 0.3); +} + +/* Ultra-compact layout for upload progress */ +.upload-progress-info ~ .target-nodes-section .target-node-item { + padding: 0.375rem 0.5rem; + gap: 0.5rem; + border-radius: 4px; + margin-bottom: 0.125rem; +} + +.upload-progress-info ~ .target-nodes-section .target-node-item .node-info { + gap: 0.0625rem; +} + +.upload-progress-info ~ .target-nodes-section .target-node-item .node-info .node-name { + font-size: 0.8rem; + line-height: 1.0; +} + +.upload-progress-info ~ .target-nodes-section .target-node-item .node-info .node-ip { + font-size: 0.7rem; + line-height: 1.0; +} + +.upload-progress-info ~ .target-nodes-section .target-node-item .node-status { + min-width: 70px; + justify-content: flex-end; + align-items: center; +} + +.upload-progress-info ~ .target-nodes-section .status-indicator { + padding: 0.2rem 0.4rem; + font-size: 0.65rem; + min-width: 70px; + border-radius: 3px; +} + +.upload-progress-info ~ .target-nodes-section .target-nodes-list { + gap: 0.125rem; +} + +.target-node-item .progress-time { + font-size: 0.75rem; + color: var(--text-tertiary); + font-style: italic; + line-height: 1.2; + margin-top: 0.25rem; +} + +/* Responsive design for target nodes */ +@media (max-width: 768px) { + .target-node-item { + padding: 0.75rem 1rem; + gap: 1rem; + } + + .target-node-item .node-status { + min-width: 120px; + } + + .status-indicator { + padding: 0.4rem 0.6rem; + font-size: 0.75rem; + min-width: 80px; + } + + .target-node-item .node-info .node-name { + font-size: 0.9rem; + } + + .target-node-item .node-info .node-ip { + font-size: 0.8rem; + } +} + +@media (max-width: 480px) { + .target-node-item { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + padding: 0.75rem; + } + + .target-node-item .node-status { + align-items: flex-start; + min-width: auto; + text-align: left; + } + + .status-indicator { + min-width: auto; + width: fit-content; + } +} + +/* Upload progress info styles */ +.upload-progress-info { + background: var(--bg-secondary); + border-radius: 12px; + border: 1px solid var(--border-secondary); + padding: 1rem; + margin-bottom: 1rem; +} + +.upload-progress-info .overall-progress { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.upload-progress-info .progress-bar-container { + flex: 1; + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border-primary); +} + +.upload-progress-info .progress-bar { + height: 100%; + transition: width 0.3s ease; +} + +.upload-progress-info .progress-text { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + min-width: 120px; + text-align: right; +} + +.upload-progress-info .progress-summary { + font-size: 0.9rem; + color: var(--text-secondary); + font-style: italic; +} + +.target-node-item .node-ip { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.85rem; + color: var(--text-tertiary); +} + +#firmware-progress-container { + flex: 1; + min-height: 200px; +} + +/* Firmware Upload Progress Styles */ +.firmware-upload-progress { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.progress-header { + padding: 1rem; + background: var(--bg-secondary); + border-radius: 12px; + border: 1px solid var(--border-secondary); +} + +.progress-header h3 { + margin: 0 0 1rem 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +.progress-info { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.overall-progress { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.progress-bar-container { + flex: 1; + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border-primary); +} + +.progress-bar { + height: 100%; + transition: width 0.3s ease; + border-radius: 4px; +} + +.progress-text { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary); + min-width: 120px; +} + +.progress-summary { + font-size: 0.9rem; + color: var(--text-secondary); +} + +.progress-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.progress-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-secondary); +} + +.progress-node-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.progress-node-info .node-name { + font-weight: 500; + color: var(--text-primary); +} + +.progress-node-info .node-ip { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8rem; + color: var(--text-tertiary); +} + +.progress-status { + font-size: 0.9rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-secondary); +} + +.progress-status.success { + background: rgba(74, 222, 128, 0.2); + color: var(--accent-primary); + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.progress-status.error { + background: rgba(248, 113, 113, 0.2); + color: var(--accent-error); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +.progress-status.uploading { + background: rgba(251, 191, 36, 0.2); + color: var(--accent-warning); + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.progress-time { + font-size: 0.8rem; + color: var(--text-tertiary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; +} + +/* Firmware Upload Progress Overlay */ +.firmware-upload-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 9998; /* Lower than drawer (1000) but higher than main content */ + display: flex; + align-items: center; + justify-content: center; + transition: all 0.25s ease; /* Smooth transition when drawer opens/closes */ +} + +/* When drawer is open, adjust overlay to only cover left side */ +.firmware-upload-overlay.drawer-open { + width: calc(100% - clamp(33.333vw, 650px, 90vw)); + justify-content: center; + align-items: center; + padding-left: 0; +} + +.overlay-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 2rem; + background: var(--bg-primary); + border-radius: 16px; + border: 1px solid var(--border-primary); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); + max-width: 400px; + text-align: center; +} + +.overlay-spinner { + display: flex; + align-items: center; + justify-content: center; +} + +.overlay-spinner .spinner { + width: 48px; + height: 48px; + color: var(--accent-primary); + animation: spin 1s linear infinite; +} + +.overlay-text { + font-size: 1.1rem; + font-weight: 500; + color: var(--text-primary); + text-align: center; +} + +.overlay-subtext { + font-size: 0.9rem; + color: var(--text-secondary); + text-align: center; + opacity: 0.8; +} + /* Only enable drawer on wider screens; on small keep inline cards */ @media (max-width: 1023px) { .details-drawer { display: none; } + + /* On mobile, overlay covers full screen since drawer is not used */ + .firmware-upload-overlay.drawer-open { + width: 100%; + justify-content: center; + padding-left: 0; + } +} + +/* Responsive adjustments for firmware upload controls */ +@media (max-width: 768px) { + .file-input-wrapper { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .file-input-left { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .upload-btn-compact, + .deploy-btn-compact { + width: 100%; + justify-content: center; + } + + .file-info { + text-align: center; + order: -1; /* Show file info first on mobile */ + } } /* Terminal Panel - bottom-centered modal style */ @@ -4854,9 +5500,9 @@ html { margin-right: 0.25rem; } -.node-status { - margin-bottom: 0.75rem; -} + + + .node-labels { margin-bottom: 0.75rem; -- 2.49.1 From 79a28bae22d3c705a9f0f64c5b99e5968d8d912b Mon Sep 17 00:00:00 2001 From: 0x1d Date: Thu, 16 Oct 2025 22:00:19 +0200 Subject: [PATCH 2/2] feat: introduce overlay dialog component --- public/index.html | 1 + .../components/ClusterViewComponent.js | 41 +++++- .../scripts/components/FirmwareComponent.js | 118 ++++++++++++++-- .../components/FirmwareUploadComponent.js | 85 ++++++++++-- .../components/OverlayDialogComponent.js | 126 +++++++++++++++++ public/styles/main.css | 128 ++++++++++++++++++ public/styles/theme.css | 110 +++++++++++++++ 7 files changed, 584 insertions(+), 25 deletions(-) create mode 100644 public/scripts/components/OverlayDialogComponent.js diff --git a/public/index.html b/public/index.html index faa8101..a46fcf2 100644 --- a/public/index.html +++ b/public/index.html @@ -263,6 +263,7 @@ + diff --git a/public/scripts/components/ClusterViewComponent.js b/public/scripts/components/ClusterViewComponent.js index 7f200b2..c581946 100644 --- a/public/scripts/components/ClusterViewComponent.js +++ b/public/scripts/components/ClusterViewComponent.js @@ -33,6 +33,9 @@ class ClusterViewComponent extends Component { // Track if we've already loaded data to prevent unnecessary reloads this.dataLoaded = false; + + // Initialize overlay dialog + this.overlayDialog = null; } mount() { @@ -50,6 +53,9 @@ class ClusterViewComponent extends Component { // Set up deploy button event listener this.setupDeployButton(); + // Initialize overlay dialog + this.initializeOverlayDialog(); + // Only load data if we haven't already or if the view model is empty const members = this.viewModel.get('members'); const shouldLoadData = true; // always perform initial refresh quickly @@ -106,6 +112,32 @@ class ClusterViewComponent extends Component { } } + initializeOverlayDialog() { + // Create overlay container if it doesn't exist + let overlayContainer = document.getElementById('cluster-overlay-dialog'); + if (!overlayContainer) { + overlayContainer = document.createElement('div'); + overlayContainer.id = 'cluster-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(); + } + } + + showConfirmationDialog(options) { + if (!this.overlayDialog) { + this.initializeOverlayDialog(); + } + + this.overlayDialog.show(options); + } + async handleDeploy() { logger.debug('ClusterViewComponent: Deploy button clicked, opening firmware upload drawer...'); @@ -113,7 +145,14 @@ class ClusterViewComponent extends Component { const filteredMembers = this.clusterMembersComponent ? this.clusterMembersComponent.getFilteredMembers() : []; if (!filteredMembers || filteredMembers.length === 0) { - alert('No nodes available for firmware deployment. Please ensure cluster members are loaded and visible.'); + this.showConfirmationDialog({ + title: 'No Nodes Available', + message: 'No nodes available for firmware deployment. Please ensure cluster members are loaded and visible.', + confirmText: 'OK', + cancelText: null, + onConfirm: () => {}, + onCancel: null + }); return; } diff --git a/public/scripts/components/FirmwareComponent.js b/public/scripts/components/FirmwareComponent.js index 83c385f..535578c 100644 --- a/public/scripts/components/FirmwareComponent.js +++ b/public/scripts/components/FirmwareComponent.js @@ -7,6 +7,9 @@ class FirmwareComponent extends Component { 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'); @@ -105,6 +108,9 @@ class FirmwareComponent extends Component { 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); @@ -131,6 +137,24 @@ class FirmwareComponent extends Component { 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); @@ -160,15 +184,69 @@ class FirmwareComponent extends Component { const specificNode = this.viewModel.get('specificNode'); if (!file) { - alert('Please select a firmware file first.'); + 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) { - alert('Please select a specific node to update.'); + 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?

This will update:
${nodes.map(n => `• ${n.hostname || n.ip}`).join('
')}`; + } 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(', ')})?

This will update:
${nodes.map(n => `• ${n.hostname || n.ip}`).join('
')}`; + } + + 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(); @@ -185,7 +263,14 @@ class FirmwareComponent extends Component { } catch (error) { logger.error('Firmware deployment failed:', error); - alert(`Deployment failed: ${error.message}`); + this.showConfirmationDialog({ + title: 'Deployment Failed', + message: `Deployment failed: ${error.message}`, + confirmText: 'OK', + cancelText: null, + onConfirm: () => {}, + onCancel: null + }); } finally { this.viewModel.completeUpload(); } @@ -198,13 +283,17 @@ class FirmwareComponent extends Component { const nodes = response.members || []; if (nodes.length === 0) { - alert('No nodes available for firmware update.'); + this.showConfirmationDialog({ + title: 'No Nodes Available', + message: 'No nodes available for firmware update.', + confirmText: 'OK', + cancelText: null, + onConfirm: () => {}, + onCancel: null + }); 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); @@ -222,9 +311,6 @@ class FirmwareComponent extends Component { 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 }]); @@ -266,12 +352,16 @@ class FirmwareComponent extends Component { try { const nodes = this.viewModel.getAffectedNodesByLabels(); if (!nodes || nodes.length === 0) { - alert('No nodes match the selected labels.'); + this.showConfirmationDialog({ + title: 'No Matching Nodes', + message: 'No nodes match the selected labels.', + confirmText: 'OK', + cancelText: null, + onConfirm: () => {}, + onCancel: null + }); 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); diff --git a/public/scripts/components/FirmwareUploadComponent.js b/public/scripts/components/FirmwareUploadComponent.js index 8aed430..4799ba2 100644 --- a/public/scripts/components/FirmwareUploadComponent.js +++ b/public/scripts/components/FirmwareUploadComponent.js @@ -6,6 +6,9 @@ class FirmwareUploadComponent extends Component { logger.debug('FirmwareUploadComponent: Constructor called'); logger.debug('FirmwareUploadComponent: Container:', container); logger.debug('FirmwareUploadComponent: Container ID:', container?.id); + + // Initialize overlay dialog + this.overlayDialog = null; } setupEventListeners() { @@ -37,6 +40,9 @@ class FirmwareUploadComponent extends Component { logger.debug('FirmwareUploadComponent: Mounting...'); + // Initialize overlay dialog + this.initializeOverlayDialog(); + // Initialize UI state this.updateFileInfo(); this.updateDeployButton(); @@ -49,6 +55,32 @@ class FirmwareUploadComponent extends Component { this.updateDeployButton(); } + initializeOverlayDialog() { + // Create overlay container if it doesn't exist + let overlayContainer = document.getElementById('firmware-upload-overlay-dialog'); + if (!overlayContainer) { + overlayContainer = document.createElement('div'); + overlayContainer.id = 'firmware-upload-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(); + } + } + + showConfirmationDialog(options) { + if (!this.overlayDialog) { + this.initializeOverlayDialog(); + } + + this.overlayDialog.show(options); + } + handleFileSelect(event) { const file = event.target.files[0]; this.viewModel.setSelectedFile(file); @@ -59,28 +91,54 @@ class FirmwareUploadComponent extends Component { const targetNodes = this.viewModel.get('targetNodes'); if (!file) { - alert('Please select a firmware file first.'); + this.showConfirmationDialog({ + title: 'No File Selected', + message: 'Please select a firmware file first.', + confirmText: 'OK', + cancelText: null, + onConfirm: () => {}, + onCancel: null + }); return; } if (!targetNodes || targetNodes.length === 0) { - alert('No target nodes available for firmware update.'); + this.showConfirmationDialog({ + title: 'No Target Nodes', + message: 'No target nodes available for firmware update.', + confirmText: 'OK', + cancelText: null, + onConfirm: () => {}, + onCancel: null + }); return; } + // Show confirmation dialog for deployment + this.showDeploymentConfirmation(file, targetNodes); + } + + showDeploymentConfirmation(file, targetNodes) { + const title = 'Deploy Firmware'; + const message = `Upload firmware "${file.name}" to ${targetNodes.length} node(s)?

Target nodes:
${targetNodes.map(n => `• ${n.hostname || n.ip} (${n.ip})`).join('
')}

This will update the firmware on all selected nodes.`; + + this.showConfirmationDialog({ + title: title, + message: message, + confirmText: 'Deploy', + cancelText: 'Cancel', + onConfirm: () => this.performDeployment(file, targetNodes), + onCancel: () => {} + }); + } + + async performDeployment(file, targetNodes) { try { this.viewModel.startUpload(); // Show progress overlay to block UI interactions this.showProgressOverlay(); - const confirmed = confirm(`Upload firmware to ${targetNodes.length} node(s)?\n\nTarget nodes:\n${targetNodes.map(n => `• ${n.hostname || n.ip} (${n.ip})`).join('\n')}\n\nThis will update the firmware on all selected nodes.`); - if (!confirmed) { - this.viewModel.completeUpload(); - this.hideProgressOverlay(); - return; - } - // Show upload progress area this.showUploadProgress(file, targetNodes); @@ -95,7 +153,14 @@ class FirmwareUploadComponent extends Component { } catch (error) { logger.error('Firmware deployment failed:', error); - alert(`Deployment failed: ${error.message}`); + this.showConfirmationDialog({ + title: 'Deployment Failed', + message: `Deployment failed: ${error.message}`, + confirmText: 'OK', + cancelText: null, + onConfirm: () => {}, + onCancel: null + }); } finally { this.viewModel.completeUpload(); this.hideProgressOverlay(); diff --git a/public/scripts/components/OverlayDialogComponent.js b/public/scripts/components/OverlayDialogComponent.js new file mode 100644 index 0000000..50a6f71 --- /dev/null +++ b/public/scripts/components/OverlayDialogComponent.js @@ -0,0 +1,126 @@ +// Overlay Dialog Component - Reusable confirmation dialog +class OverlayDialogComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + this.isVisible = false; + this.onConfirm = null; + this.onCancel = null; + this.title = ''; + this.message = ''; + this.confirmText = 'Yes'; + this.cancelText = 'No'; + } + + mount() { + super.mount(); + this.setupEventListeners(); + } + + setupEventListeners() { + // Close overlay when clicking outside or pressing escape + this.addEventListener(this.container, 'click', (e) => { + if (!this.isVisible) return; + if (e.target === this.container) { + this.hide(); + } + }); + + this.addEventListener(document, 'keydown', (e) => { + if (e.key === 'Escape' && this.isVisible) { + this.hide(); + } + }); + } + + show(options = {}) { + const { + title = 'Confirm Action', + message = 'Are you sure you want to proceed?', + confirmText = 'Yes', + cancelText = 'No', + onConfirm = null, + onCancel = null + } = options; + + this.title = title; + this.message = message; + this.confirmText = confirmText; + this.cancelText = cancelText; + this.onConfirm = onConfirm; + this.onCancel = onCancel; + + this.render(); + this.container.classList.add('visible'); + this.isVisible = true; + } + + hide() { + this.container.classList.remove('visible'); + this.isVisible = false; + + // Call cancel callback if provided + if (this.onCancel) { + this.onCancel(); + } + } + + handleConfirm() { + this.hide(); + + // Call confirm callback if provided + if (this.onConfirm) { + this.onConfirm(); + } + } + + render() { + this.container.innerHTML = ` +
+
+

${this.title}

+ +
+
+
${this.message}
+
+ +
+ `; + + // Add event listeners to buttons + const closeBtn = this.container.querySelector('.overlay-dialog-close'); + const cancelBtn = this.container.querySelector('.overlay-dialog-btn-cancel'); + const confirmBtn = this.container.querySelector('.overlay-dialog-btn-confirm'); + + if (closeBtn) { + this.addEventListener(closeBtn, 'click', () => this.hide()); + } + + if (cancelBtn) { + this.addEventListener(cancelBtn, 'click', () => this.hide()); + } + + if (confirmBtn) { + this.addEventListener(confirmBtn, 'click', () => this.handleConfirm()); + } + } + + unmount() { + // Clean up event listeners + this.removeAllEventListeners(); + + // Call parent unmount + super.unmount(); + } +} diff --git a/public/styles/main.css b/public/styles/main.css index 0844a57..08c69e1 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -6100,3 +6100,131 @@ html { justify-content: center; } } + +/* Overlay Dialog Styles */ +.overlay-dialog { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.overlay-dialog.visible { + opacity: 1; + visibility: visible; +} + +.overlay-dialog-content { + background: linear-gradient(135deg, #1c2a38 0%, #283746 50%, #1a252f 100%); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow: hidden; + transform: scale(0.9) translateY(20px); + transition: all 0.3s ease; +} + +.overlay-dialog.visible .overlay-dialog-content { + transform: scale(1) translateY(0); +} + +.overlay-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 24px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.overlay-dialog-title { + font-size: 1.25rem; + font-weight: 600; + color: #ecf0f1; + margin: 0; +} + +.overlay-dialog-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + padding: 8px; + border-radius: 8px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.overlay-dialog-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #ecf0f1; +} + +.overlay-dialog-body { + padding: 16px 24px; +} + +.overlay-dialog-message { + color: rgba(255, 255, 255, 0.8); + font-size: 1rem; + line-height: 1.5; + margin: 0; + white-space: normal; +} + +.overlay-dialog-footer { + display: flex; + gap: 12px; + padding: 16px 24px 24px; + justify-content: flex-end; +} + +.overlay-dialog-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; + min-width: 80px; +} + +.overlay-dialog-btn-cancel { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.8); +} + +.overlay-dialog-btn-cancel:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); + border-color: rgba(255, 255, 255, 0.3); + color: #ecf0f1; + transform: translateY(-1px); +} + +.overlay-dialog-btn-confirm { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + border: 1px solid #3b82f6; + color: white; +} + +.overlay-dialog-btn-confirm:hover { + background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); + border-color: #2563eb; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} diff --git a/public/styles/theme.css b/public/styles/theme.css index 373203c..db8b4a2 100644 --- a/public/styles/theme.css +++ b/public/styles/theme.css @@ -1095,6 +1095,61 @@ } } +/* Overlay Dialog Light Theme Styles */ +[data-theme="light"] .overlay-dialog-content { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.95) 100%); + backdrop-filter: blur(24px); + border: 1px solid rgba(148, 163, 184, 0.3); + box-shadow: 0 20px 40px rgba(148, 163, 184, 0.12); +} + +[data-theme="light"] .overlay-dialog-header { + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +[data-theme="light"] .overlay-dialog-title { + color: var(--text-primary) !important; + font-weight: 600; +} + +[data-theme="light"] .overlay-dialog-close { + color: var(--text-tertiary) !important; +} + +[data-theme="light"] .overlay-dialog-close:hover { + background: rgba(255, 255, 255, 0.6); + color: var(--text-primary) !important; +} + +[data-theme="light"] .overlay-dialog-message { + color: var(--text-secondary) !important; + white-space: normal; +} + +[data-theme="light"] .overlay-dialog-btn-cancel { + background: linear-gradient(135deg, rgba(148, 163, 184, 0.1) 0%, rgba(148, 163, 184, 0.05) 100%); + border: 1px solid rgba(148, 163, 184, 0.2); + color: var(--text-secondary) !important; +} + +[data-theme="light"] .overlay-dialog-btn-cancel:hover { + background: linear-gradient(135deg, rgba(148, 163, 184, 0.15) 0%, rgba(148, 163, 184, 0.08) 100%); + border-color: rgba(148, 163, 184, 0.3); + color: var(--text-primary) !important; +} + +[data-theme="light"] .overlay-dialog-btn-confirm { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + border: 1px solid #3b82f6; + color: white; +} + +[data-theme="light"] .overlay-dialog-btn-confirm:hover { + background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); + border-color: #2563eb; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + /* Ultra-specific mobile navigation override for light theme */ @media (max-width: 768px) { html[data-theme="light"] .main-navigation.mobile-open .nav-left { @@ -1246,3 +1301,58 @@ box-shadow: 0 6px 20px rgba(148, 163, 184, 0.15) !important; } } + +/* Overlay Dialog Light Theme Styles */ +[data-theme="light"] .overlay-dialog-content { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.95) 100%); + backdrop-filter: blur(24px); + border: 1px solid rgba(148, 163, 184, 0.3); + box-shadow: 0 20px 40px rgba(148, 163, 184, 0.12); +} + +[data-theme="light"] .overlay-dialog-header { + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +[data-theme="light"] .overlay-dialog-title { + color: var(--text-primary) !important; + font-weight: 600; +} + +[data-theme="light"] .overlay-dialog-close { + color: var(--text-tertiary) !important; +} + +[data-theme="light"] .overlay-dialog-close:hover { + background: rgba(255, 255, 255, 0.6); + color: var(--text-primary) !important; +} + +[data-theme="light"] .overlay-dialog-message { + color: var(--text-secondary) !important; + white-space: normal; +} + +[data-theme="light"] .overlay-dialog-btn-cancel { + background: linear-gradient(135deg, rgba(148, 163, 184, 0.1) 0%, rgba(148, 163, 184, 0.05) 100%); + border: 1px solid rgba(148, 163, 184, 0.2); + color: var(--text-secondary) !important; +} + +[data-theme="light"] .overlay-dialog-btn-cancel:hover { + background: linear-gradient(135deg, rgba(148, 163, 184, 0.15) 0%, rgba(148, 163, 184, 0.08) 100%); + border-color: rgba(148, 163, 184, 0.3); + color: var(--text-primary) !important; +} + +[data-theme="light"] .overlay-dialog-btn-confirm { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + border: 1px solid #3b82f6; + color: white; +} + +[data-theme="light"] .overlay-dialog-btn-confirm:hover { + background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); + border-color: #2563eb; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} -- 2.49.1