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;