From 79a28bae22d3c705a9f0f64c5b99e5968d8d912b Mon Sep 17 00:00:00 2001 From: 0x1d Date: Thu, 16 Oct 2025 22:00:19 +0200 Subject: [PATCH] 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); +}