From f73dd4d0e92910aaf65e51ae089da249d9148e69 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Sat, 18 Oct 2025 10:41:54 +0200 Subject: [PATCH] feat: introduce global config dialog --- public/index.html | 8 + .../components/ClusterViewComponent.js | 117 +++++++ public/scripts/components/ComponentsLoader.js | 2 +- .../scripts/components/WiFiConfigComponent.js | 313 ++++++++++++++++++ public/scripts/view-models.js | 80 ++++- public/styles/main.css | 272 ++++++++++++++- 6 files changed, 788 insertions(+), 4 deletions(-) create mode 100644 public/scripts/components/WiFiConfigComponent.js diff --git a/public/index.html b/public/index.html index a46fcf2..4ab9d37 100644 --- a/public/index.html +++ b/public/index.html @@ -103,6 +103,13 @@
+ +
+ +
+
+
+
+ + +
+ +
+ + +
+ +
+ +
+
+ + + + + + + Affected Nodes: ${targetNodes.length} +
+
+ +
+ +
+
+ +
+ +
+
+
+ + + `; + + // Create and mount WiFi config component + const wifiConfigComponent = new WiFiConfigComponent(contentContainer, wifiConfigVM, this.eventBus); + setActiveComponent(wifiConfigComponent); + wifiConfigComponent.mount(); + + }, null, () => { + // Close callback - clear any config state + logger.debug('ClusterViewComponent: WiFi config drawer closed'); + }, true); // Hide terminal button for WiFi config + } + async handleRefresh() { logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...'); diff --git a/public/scripts/components/ComponentsLoader.js b/public/scripts/components/ComponentsLoader.js index a9d7230..f294a9e 100644 --- a/public/scripts/components/ComponentsLoader.js +++ b/public/scripts/components/ComponentsLoader.js @@ -1,7 +1,7 @@ (function(){ // Simple readiness flag once all component constructors are present function allReady(){ - return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent); + return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent && window.WiFiConfigComponent); } window.waitForComponentsReady = function(timeoutMs = 5000){ return new Promise((resolve, reject) => { diff --git a/public/scripts/components/WiFiConfigComponent.js b/public/scripts/components/WiFiConfigComponent.js new file mode 100644 index 0000000..468b991 --- /dev/null +++ b/public/scripts/components/WiFiConfigComponent.js @@ -0,0 +1,313 @@ +// WiFi Configuration Component +class WiFiConfigComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('WiFiConfigComponent: Constructor called'); + logger.debug('WiFiConfigComponent: Container:', container); + + // Track form state + this.formValid = false; + } + + mount() { + logger.debug('WiFiConfigComponent: Mounting...'); + super.mount(); + + this.setupFormValidation(); + this.setupApplyButton(); + this.setupProgressDisplay(); + + // Initial validation to ensure button starts disabled + this.validateForm(); + + logger.debug('WiFiConfigComponent: Mounted successfully'); + } + + setupFormValidation() { + logger.debug('WiFiConfigComponent: Setting up form validation...'); + + const ssidInput = this.findElement('#wifi-ssid'); + const passwordInput = this.findElement('#wifi-password'); + const applyBtn = this.findElement('#apply-wifi-config'); + + if (!ssidInput || !passwordInput || !applyBtn) { + logger.error('WiFiConfigComponent: Required form elements not found'); + return; + } + + // Add input event listeners + this.addEventListener(ssidInput, 'input', this.validateForm.bind(this)); + this.addEventListener(passwordInput, 'input', this.validateForm.bind(this)); + + // Initial validation + this.validateForm(); + } + + setupApplyButton() { + logger.debug('WiFiConfigComponent: Setting up apply button...'); + + const applyBtn = this.findElement('#apply-wifi-config'); + + if (applyBtn) { + this.addEventListener(applyBtn, 'click', this.handleApply.bind(this)); + logger.debug('WiFiConfigComponent: Apply button event listener added'); + } else { + logger.error('WiFiConfigComponent: Apply button not found'); + } + } + + setupProgressDisplay() { + logger.debug('WiFiConfigComponent: Setting up progress display...'); + + // Subscribe to view model changes + this.viewModel.subscribe('isConfiguring', (isConfiguring) => { + this.updateApplyButton(isConfiguring); + }); + + this.viewModel.subscribe('configProgress', (progress) => { + this.updateProgressDisplay(progress); + }); + + this.viewModel.subscribe('configResults', (results) => { + this.updateResultsDisplay(results); + }); + } + + validateForm() { + logger.debug('WiFiConfigComponent: Validating form...'); + + const ssidInput = this.findElement('#wifi-ssid'); + const passwordInput = this.findElement('#wifi-password'); + const applyBtn = this.findElement('#apply-wifi-config'); + + if (!ssidInput || !passwordInput || !applyBtn) { + return; + } + + const ssid = ssidInput.value.trim(); + const password = passwordInput.value.trim(); + + this.formValid = ssid.length > 0 && password.length > 0; + + // Update apply button state + applyBtn.disabled = !this.formValid; + + // Update view model + this.viewModel.setCredentials(ssid, password); + + logger.debug('WiFiConfigComponent: Form validation complete. Valid:', this.formValid); + } + + updateApplyButton(isConfiguring) { + logger.debug('WiFiConfigComponent: Updating apply button. Configuring:', isConfiguring); + + const applyBtn = this.findElement('#apply-wifi-config'); + + if (!applyBtn) { + return; + } + + if (isConfiguring) { + applyBtn.disabled = true; + applyBtn.classList.add('loading'); + applyBtn.innerHTML = `Apply`; + } else { + applyBtn.disabled = !this.formValid; + applyBtn.classList.remove('loading'); + applyBtn.innerHTML = `Apply`; + } + } + + updateProgressDisplay(progress) { + logger.debug('WiFiConfigComponent: Updating progress display:', progress); + + const progressContainer = this.findElement('#wifi-progress-container'); + + if (!progressContainer || !progress) { + return; + } + + const { current, total, status } = progress; + const percentage = total > 0 ? Math.round((current / total) * 100) : 0; + + progressContainer.innerHTML = ` +
+
+
+
+
+ ${current}/${total} Configured (${percentage}%) +
+
+ Status: ${status} +
+
+ `; + } + + showProgressBar(totalNodes) { + logger.debug('WiFiConfigComponent: Showing initial progress bar for', totalNodes, 'nodes'); + + const progressContainer = this.findElement('#wifi-progress-container'); + + if (!progressContainer) { + return; + } + + progressContainer.innerHTML = ` +
+
+
+
+
+ 0/${totalNodes} Configured (0%) +
+
+ Status: Preparing configuration... +
+
+ `; + } + + updateResultsDisplay(results) { + logger.debug('WiFiConfigComponent: Updating results display:', results); + + const progressContainer = this.findElement('#wifi-progress-container'); + + if (!progressContainer || !results || results.length === 0) { + return; + } + + const resultsHtml = results.map(result => ` +
+
+ ${result.node.hostname || result.node.ip} + ${result.node.ip} +
+
+ + ${result.success ? 'Success' : 'Failed'} + +
+ ${result.error ? `
${this.escapeHtml(result.error)}
` : ''} +
+ `).join(''); + + // Append results to existing progress container + const existingProgress = progressContainer.querySelector('.upload-progress-info'); + if (existingProgress) { + existingProgress.innerHTML += ` +
+
+ ${resultsHtml} +
+
+ `; + } + } + + async handleApply() { + logger.debug('WiFiConfigComponent: Apply button clicked'); + + if (!this.formValid) { + logger.warn('WiFiConfigComponent: Form is not valid, cannot apply'); + return; + } + + const ssid = this.findElement('#wifi-ssid').value.trim(); + const password = this.findElement('#wifi-password').value.trim(); + const targetNodes = this.viewModel.get('targetNodes'); + + logger.debug('WiFiConfigComponent: Applying WiFi config to', targetNodes.length, 'nodes'); + logger.debug('WiFiConfigComponent: SSID:', ssid); + + // Start configuration + this.viewModel.startConfiguration(); + + // Show initial progress bar + this.showProgressBar(targetNodes.length); + + try { + // Update progress + this.viewModel.updateConfigProgress(0, targetNodes.length, 'Starting configuration...'); + + // Apply configuration to each node + for (let i = 0; i < targetNodes.length; i++) { + const node = targetNodes[i]; + + try { + logger.debug('WiFiConfigComponent: Configuring node:', node.ip); + + // Update progress + this.viewModel.updateConfigProgress(i + 1, targetNodes.length, `Configuring ${node.hostname || node.ip}...`); + + // Make API call to configure WiFi + const result = await this.configureNodeWiFi(node, ssid, password); + + // Add successful result + this.viewModel.addConfigResult({ + node, + success: true, + error: null + }); + + logger.debug('WiFiConfigComponent: Successfully configured node:', node.ip); + + } catch (error) { + logger.error('WiFiConfigComponent: Failed to configure node:', node.ip, error); + + // Add failed result + this.viewModel.addConfigResult({ + node, + success: false, + error: error.message || 'Configuration failed' + }); + } + + // Small delay between requests to avoid overwhelming the nodes + if (i < targetNodes.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + // Complete configuration + this.viewModel.updateConfigProgress(targetNodes.length, targetNodes.length, 'Configuration complete'); + this.viewModel.completeConfiguration(); + + logger.debug('WiFiConfigComponent: WiFi configuration completed'); + + } catch (error) { + logger.error('WiFiConfigComponent: WiFi configuration failed:', error); + this.viewModel.completeConfiguration(); + } + } + + async configureNodeWiFi(node, ssid, password) { + logger.debug('WiFiConfigComponent: Configuring WiFi for node:', node.ip); + + const response = await window.apiClient.callEndpoint({ + ip: node.ip, + method: 'POST', + uri: '/api/network/wifi/config', + params: [ + { name: 'ssid', value: ssid, location: 'body' }, + { name: 'password', value: password, location: 'body' } + ] + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to configure WiFi'); + } + + return response; + } + + unmount() { + logger.debug('WiFiConfigComponent: Unmounting...'); + super.unmount(); + logger.debug('WiFiConfigComponent: Unmounted'); + } +} + +window.WiFiConfigComponent = WiFiConfigComponent; diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index 1e64722..ffff265 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -1031,4 +1031,82 @@ class ClusterFirmwareViewModel extends ViewModel { return file && targetNodes && targetNodes.length > 0 && !isUploading; } -} \ No newline at end of file +} + +// WiFi Configuration View Model +class WiFiConfigViewModel extends ViewModel { + constructor() { + super(); + this.set('targetNodes', []); + this.set('ssid', ''); + this.set('password', ''); + this.set('isConfiguring', false); + this.set('configProgress', null); + this.set('configResults', []); + } + + // Set target nodes (filtered from cluster view) + setTargetNodes(nodes) { + this.set('targetNodes', nodes); + } + + // Set WiFi credentials + setCredentials(ssid, password) { + this.set('ssid', ssid); + this.set('password', password); + } + + // Start configuration + startConfiguration() { + this.set('isConfiguring', true); + this.set('configProgress', { + current: 0, + total: 0, + status: 'Preparing...' + }); + this.set('configResults', []); + } + + // Update configuration progress + updateConfigProgress(current, total, status) { + this.set('configProgress', { + current, + total, + status + }); + } + + // Add configuration result + addConfigResult(result) { + const results = this.get('configResults'); + results.push(result); + this.set('configResults', results); + } + + // Complete configuration + completeConfiguration() { + this.set('isConfiguring', false); + this.set('configProgress', null); + } + + // Reset configuration state + resetConfiguration() { + this.set('ssid', ''); + this.set('password', ''); + this.set('configProgress', null); + this.set('configResults', []); + this.set('isConfiguring', false); + } + + // Check if apply is enabled + isApplyEnabled() { + const ssid = this.get('ssid'); + const password = this.get('password'); + const targetNodes = this.get('targetNodes'); + const isConfiguring = this.get('isConfiguring'); + + return ssid && password && targetNodes && targetNodes.length > 0 && !isConfiguring; + } +} + +window.WiFiConfigViewModel = WiFiConfigViewModel; \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index 28c053a..045abb3 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -401,6 +401,46 @@ p { } } +/* Config Button */ +.config-btn { + 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.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; + gap: 0.5rem; + backdrop-filter: var(--backdrop-blur); + margin: 0; +} + +.config-btn: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(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.config-btn:active { + transform: translateY(0); +} + +.config-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.config-btn.loading { + opacity: 0.8; + cursor: not-allowed; +} + /* Deploy Button */ .deploy-btn { background: linear-gradient(135deg, rgba(74, 222, 128, 0.2) 0%, rgba(74, 222, 128, 0.1) 100%); @@ -440,6 +480,218 @@ p { cursor: not-allowed; } +/* WiFi Configuration Styles */ +.wifi-config-drawer { + padding: 0; +} + +.wifi-config-section { + padding: 1rem; +} + +.wifi-config-section h3 { + display: flex; + align-items: center; + margin-bottom: 1rem; + color: var(--text-primary); + font-size: 1rem; + font-weight: 600; +} + +.wifi-form { + margin-bottom: 2rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-secondary); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.9rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.form-group input:focus { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); +} + +.form-group input::placeholder { + color: var(--text-tertiary); +} + +.wifi-divider { + height: 1px; + background: var(--border-primary); + margin: 1.5rem 0; + opacity: 0.6; +} + +.affected-nodes-info { + margin-bottom: 1.5rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; +} + +.nodes-count { + display: flex; + align-items: center; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; +} + +.nodes-count span { + color: var(--text-primary); + font-weight: 600; +} + +.wifi-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.wifi-actions .config-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.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; +} + +.wifi-actions .config-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(-1px); + box-shadow: 0 2px 8px rgba(74, 222, 128, 0.2); +} + +.wifi-actions .config-btn:active { + transform: translateY(0); +} + +.wifi-actions .config-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.wifi-actions .config-btn.loading { + opacity: 0.8; + cursor: not-allowed; +} + +/* Results Section */ +.results-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-primary); +} + +.results-section h3 { + display: flex; + align-items: center; + margin-bottom: 1rem; + color: var(--text-primary); + font-size: 1rem; + font-weight: 600; +} + +.results-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.result-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + border-radius: 6px; + border: 1px solid var(--border-primary); +} + +.result-item.success { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.result-item.error { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); +} + +.result-node { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.result-node .node-name { + color: var(--text-primary); + font-weight: 500; + font-size: 0.9rem; +} + +.result-node .node-ip { + color: var(--text-secondary); + font-size: 0.8rem; +} + +.result-status .status-indicator { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.8rem; + font-weight: 500; +} + +.result-status .status-indicator.success { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.result-status .status-indicator.error { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.result-error { + margin-top: 0.5rem; + padding: 0.5rem; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; + color: #ef4444; + font-size: 0.8rem; + font-family: monospace; +} + /* Cluster Header Right */ .cluster-header-right { display: flex; @@ -4847,8 +5099,7 @@ select.param-input:focus { } .clear-filters-btn { - align-self: center; - padding: 0.5rem; + display: none; } .primary-node-info { @@ -4879,6 +5130,14 @@ select.param-input:focus { padding: 0.75rem; font-size: 0.95rem; } + + .config-btn, + .deploy-btn { + width: 100%; + justify-content: center; + padding: 0.75rem; + font-size: 0.95rem; + } } #cluster-members-container { @@ -5089,6 +5348,7 @@ html { .upload-btn, .upload-btn-compact, .deploy-btn, +.config-btn, .cap-call-btn, .progress-refresh-btn, .clear-btn, @@ -5126,6 +5386,14 @@ html { #firmware-view .deploy-btn:hover:not(:disabled)::before { left: -100% !important; } + + /* Disable hover effects for cluster view buttons on touch devices */ + .config-btn:hover, + .deploy-btn:hover:not(:disabled), + .refresh-btn:hover { + transform: none !important; + box-shadow: none !important; + } } /* Cluster view specific error styling */