From eb1334735a4f2d676f7f12e2be00609cb3a0e471 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Tue, 21 Oct 2025 21:01:56 +0200 Subject: [PATCH] feat: rollout --- public/index.html | 1 + public/scripts/api-client.js | 88 ++-- .../scripts/components/FirmwareComponent.js | 298 +++++++++--- .../components/OverlayDialogComponent.js | 145 +++++- public/scripts/components/RolloutComponent.js | 271 +++++++++++ public/styles/main.css | 425 ++++++++++++++++-- public/styles/theme.css | 2 +- 7 files changed, 1059 insertions(+), 171 deletions(-) create mode 100644 public/scripts/components/RolloutComponent.js diff --git a/public/index.html b/public/index.html index 73cbed0..b54c48b 100644 --- a/public/index.html +++ b/public/index.html @@ -248,6 +248,7 @@ + diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index 3f9e6ed..2daf1ea 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -137,77 +137,42 @@ class ApiClient { }); } - // Registry API methods - async getRegistryBaseUrl() { - // Auto-detect registry server URL based on current location - const currentHost = window.location.hostname; - - // If accessing from localhost, use localhost:8080 - // If accessing from another device, use the same hostname but port 8080 - if (currentHost === 'localhost' || currentHost === '127.0.0.1') { - return 'http://localhost:8080'; - } else { - return `http://${currentHost}:8080`; - } + // Registry API methods - now proxied through gateway + async getRegistryHealth() { + return this.request('/api/registry/health', { method: 'GET' }); } async uploadFirmwareToRegistry(metadata, firmwareFile) { - const registryBaseUrl = await this.getRegistryBaseUrl(); const formData = new FormData(); formData.append('metadata', JSON.stringify(metadata)); formData.append('firmware', firmwareFile); - const response = await fetch(`${registryBaseUrl}/firmware`, { + return this.request('/api/registry/firmware', { method: 'POST', - body: formData + body: formData, + isForm: true, + headers: {} }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Registry upload failed: ${errorText}`); - } - - return await response.json(); } async updateFirmwareMetadata(name, version, metadata) { - const registryBaseUrl = await this.getRegistryBaseUrl(); - - const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { + return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(metadata) + body: metadata }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Registry metadata update failed: ${errorText}`); - } - - return await response.json(); } async listFirmwareFromRegistry(name = null, version = null) { - const registryBaseUrl = await this.getRegistryBaseUrl(); const query = {}; if (name) query.name = name; if (version) query.version = version; - const response = await fetch(`${registryBaseUrl}/firmware${Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : ''}`); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Registry list failed: ${errorText}`); - } - - return await response.json(); + const queryString = Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : ''; + return this.request(`/api/registry/firmware${queryString}`, { method: 'GET' }); } async downloadFirmwareFromRegistry(name, version) { - const registryBaseUrl = await this.getRegistryBaseUrl(); - const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`); + const response = await fetch(`${this.baseURL}/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`); if (!response.ok) { const errorText = await response.text(); @@ -217,16 +182,22 @@ class ApiClient { return response.blob(); } - async getRegistryHealth() { - const registryBaseUrl = await this.getRegistryBaseUrl(); - const response = await fetch(`${registryBaseUrl}/health`); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Registry health check failed: ${errorText}`); - } - - return await response.json(); + async deleteFirmwareFromRegistry(name, version) { + return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { + method: 'DELETE' + }); + } + + // Rollout API methods + async getClusterNodeVersions() { + return this.request('/api/cluster/node/versions', { method: 'GET' }); + } + + async startRollout(rolloutData) { + return this.request('/api/rollout', { + method: 'POST', + body: rolloutData + }); } } @@ -320,6 +291,9 @@ class WebSocketClient { case 'firmware_upload_status': this.emit('firmwareUploadStatus', data); break; + case 'rollout_progress': + this.emit('rolloutProgress', data); + break; default: logger.debug('Unknown WebSocket message type:', data.type); } diff --git a/public/scripts/components/FirmwareComponent.js b/public/scripts/components/FirmwareComponent.js index be55d50..c0db2af 100644 --- a/public/scripts/components/FirmwareComponent.js +++ b/public/scripts/components/FirmwareComponent.js @@ -53,6 +53,11 @@ class FirmwareComponent extends Component { logger.debug('FirmwareComponent: Mounted successfully'); } + unmount() { + this.cleanupDynamicListeners(); + super.unmount(); + } + async checkRegistryConnection() { try { await window.apiClient.getRegistryHealth(); @@ -168,8 +173,13 @@ class FirmwareComponent extends Component { return `
-

${this.escapeHtml(group.name)}

- ${group.firmware.length} version${group.firmware.length !== 1 ? 's' : ''} +
+

${this.escapeHtml(group.name)}

+ ${group.firmware.length} version${group.firmware.length !== 1 ? 's' : ''} +
+ + +
${versionsHTML} @@ -198,6 +208,13 @@ class FirmwareComponent extends Component {
+ ` : ''} - -
- - `; - - document.body.appendChild(overlay); - - const confirmBtn = overlay.querySelector('#confirm-btn'); - const cancelBtn = overlay.querySelector('#cancel-btn'); - - confirmBtn.addEventListener('click', () => { - document.body.removeChild(overlay); - if (options.onConfirm) options.onConfirm(); - }); - - if (cancelBtn) { - cancelBtn.addEventListener('click', () => { - document.body.removeChild(overlay); - if (options.onCancel) options.onCancel(); - }); - } - - // Close on escape key - const handleEscape = (e) => { - if (e.key === 'Escape') { - document.body.removeChild(overlay); - document.removeEventListener('keydown', handleEscape); - if (options.onCancel) options.onCancel(); - } - }; - document.addEventListener('keydown', handleEscape); - } showSuccess(message) { this.showNotification(message, 'success'); diff --git a/public/scripts/components/OverlayDialogComponent.js b/public/scripts/components/OverlayDialogComponent.js index 50a6f71..3565322 100644 --- a/public/scripts/components/OverlayDialogComponent.js +++ b/public/scripts/components/OverlayDialogComponent.js @@ -9,6 +9,8 @@ class OverlayDialogComponent extends Component { this.message = ''; this.confirmText = 'Yes'; this.cancelText = 'No'; + this.confirmClass = 'overlay-dialog-btn-confirm'; + this.showCloseButton = true; } mount() { @@ -38,6 +40,8 @@ class OverlayDialogComponent extends Component { message = 'Are you sure you want to proceed?', confirmText = 'Yes', cancelText = 'No', + confirmClass = 'overlay-dialog-btn-confirm', + showCloseButton = true, onConfirm = null, onCancel = null } = options; @@ -46,53 +50,74 @@ class OverlayDialogComponent extends Component { this.message = message; this.confirmText = confirmText; this.cancelText = cancelText; + this.confirmClass = confirmClass; + this.showCloseButton = showCloseButton; this.onConfirm = onConfirm; this.onCancel = onCancel; this.render(); - this.container.classList.add('visible'); + + // Add visible class with small delay for animation + setTimeout(() => { + this.container.classList.add('visible'); + }, 10); + this.isVisible = true; } hide() { this.container.classList.remove('visible'); - this.isVisible = false; - // Call cancel callback if provided - if (this.onCancel) { - this.onCancel(); - } + setTimeout(() => { + this.isVisible = false; + + // Call cancel callback if provided + if (this.onCancel) { + this.onCancel(); + this.onCancel = null; + } + }, 300); } handleConfirm() { - this.hide(); + this.container.classList.remove('visible'); - // Call confirm callback if provided - if (this.onConfirm) { - this.onConfirm(); - } + setTimeout(() => { + this.isVisible = false; + + // Call confirm callback if provided + if (this.onConfirm) { + this.onConfirm(); + this.onConfirm = null; + } + }, 300); } render() { this.container.innerHTML = `
-

${this.title}

- +

${this.escapeHtml(this.title)}

+ ${this.showCloseButton ? ` + + ` : ''}
-
${this.message}
+

${this.message}

@@ -101,7 +126,7 @@ class OverlayDialogComponent extends Component { // 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'); + const confirmBtn = this.container.querySelector(`.${this.confirmClass}`); if (closeBtn) { this.addEventListener(closeBtn, 'click', () => this.hide()); @@ -116,6 +141,13 @@ class OverlayDialogComponent extends Component { } } + escapeHtml(text) { + if (typeof text !== 'string') return text; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + unmount() { // Clean up event listeners this.removeAllEventListeners(); @@ -124,3 +156,68 @@ class OverlayDialogComponent extends Component { super.unmount(); } } + +// Static utility methods for easy usage without mounting +OverlayDialogComponent.show = function(options) { + // Create a temporary container + const container = document.createElement('div'); + container.className = 'overlay-dialog'; + document.body.appendChild(container); + + // Create component instance + const dialog = new OverlayDialogComponent(container, null, null); + + // Override hide to clean up container + const originalHide = dialog.hide.bind(dialog); + dialog.hide = function() { + originalHide(); + setTimeout(() => { + if (container.parentNode) { + document.body.removeChild(container); + } + }, 350); + }; + + // Override handleConfirm to clean up container + const originalHandleConfirm = dialog.handleConfirm.bind(dialog); + dialog.handleConfirm = function() { + originalHandleConfirm(); + setTimeout(() => { + if (container.parentNode) { + document.body.removeChild(container); + } + }, 350); + }; + + dialog.mount(); + dialog.show(options); + + return dialog; +}; + +// Convenience method for confirmation dialogs +OverlayDialogComponent.confirm = function(options) { + return OverlayDialogComponent.show({ + ...options, + confirmClass: options.confirmClass || 'overlay-dialog-btn-confirm' + }); +}; + +// Convenience method for danger/delete confirmations +OverlayDialogComponent.danger = function(options) { + return OverlayDialogComponent.show({ + ...options, + confirmClass: 'overlay-dialog-btn-danger' + }); +}; + +// Convenience method for alerts +OverlayDialogComponent.alert = function(message, title = 'Notice') { + return OverlayDialogComponent.show({ + title, + message, + confirmText: 'OK', + cancelText: null, + showCloseButton: false + }); +}; diff --git a/public/scripts/components/RolloutComponent.js b/public/scripts/components/RolloutComponent.js new file mode 100644 index 0000000..92676c6 --- /dev/null +++ b/public/scripts/components/RolloutComponent.js @@ -0,0 +1,271 @@ +// Rollout Component - Shows rollout panel with matching nodes and starts rollout +class RolloutComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('RolloutComponent: Constructor called'); + + this.rolloutData = null; + this.matchingNodes = []; + this.onRolloutCallback = null; + this.onCancelCallback = null; + } + + setupEventListeners() { + // Rollout button + const rolloutBtn = this.findElement('#rollout-confirm-btn'); + if (rolloutBtn) { + this.addEventListener(rolloutBtn, 'click', this.handleRollout.bind(this)); + } + + // Cancel button + const cancelBtn = this.findElement('#rollout-cancel-btn'); + if (cancelBtn) { + this.addEventListener(cancelBtn, 'click', this.handleCancel.bind(this)); + } + } + + // Start rollout - hide labels and show status indicators + startRollout() { + const nodeItems = this.container.querySelectorAll('.rollout-node-item'); + nodeItems.forEach(item => { + const labelsDiv = item.querySelector('.rollout-node-labels'); + const statusDiv = item.querySelector('.status-indicator'); + + if (labelsDiv && statusDiv) { + labelsDiv.style.display = 'none'; + statusDiv.style.display = 'block'; + statusDiv.textContent = 'Ready'; + statusDiv.className = 'status-indicator ready'; + } + }); + + // Disable the confirm button + const confirmBtn = this.findElement('#rollout-confirm-btn'); + if (confirmBtn) { + confirmBtn.disabled = true; + confirmBtn.textContent = 'Rollout in Progress...'; + } + } + + // Update status for a specific node + updateNodeStatus(nodeIp, status) { + const nodeItem = this.container.querySelector(`[data-node-ip="${nodeIp}"]`); + if (!nodeItem) return; + + const statusDiv = nodeItem.querySelector('.status-indicator'); + if (!statusDiv) return; + + let displayStatus = status; + let statusClass = ''; + + switch (status) { + case 'updating_labels': + displayStatus = 'Updating Labels...'; + statusClass = 'uploading'; + break; + case 'uploading': + displayStatus = 'Uploading...'; + statusClass = 'uploading'; + break; + case 'completed': + displayStatus = 'Completed'; + statusClass = 'success'; + break; + case 'failed': + displayStatus = 'Failed'; + statusClass = 'error'; + break; + default: + displayStatus = status; + statusClass = 'pending'; + } + + statusDiv.textContent = displayStatus; + statusDiv.className = `status-indicator ${statusClass}`; + } + + // Check if rollout is complete + isRolloutComplete() { + const statusIndicators = this.container.querySelectorAll('.status-indicator'); + for (const indicator of statusIndicators) { + const status = indicator.textContent.toLowerCase(); + if (status !== 'completed' && status !== 'failed') { + return false; + } + } + return true; + } + + // Reset to initial state (show labels, hide status indicators) + resetRolloutState() { + const nodeItems = this.container.querySelectorAll('.rollout-node-item'); + nodeItems.forEach(item => { + const labelsDiv = item.querySelector('.rollout-node-labels'); + const statusDiv = item.querySelector('.status-indicator'); + + if (labelsDiv && statusDiv) { + labelsDiv.style.display = 'block'; + statusDiv.style.display = 'none'; + } + }); + + // Re-enable the confirm button + const confirmBtn = this.findElement('#rollout-confirm-btn'); + if (confirmBtn) { + confirmBtn.disabled = false; + confirmBtn.textContent = `Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}`; + } + } + + mount() { + super.mount(); + + logger.debug('RolloutComponent: Mounting...'); + + this.render(); + + logger.debug('RolloutComponent: Mounted successfully'); + } + + setRolloutData(name, version, labels, matchingNodes) { + this.rolloutData = { name, version, labels }; + this.matchingNodes = matchingNodes; + } + + setOnRolloutCallback(callback) { + this.onRolloutCallback = callback; + } + + setOnCancelCallback(callback) { + this.onCancelCallback = callback; + } + + render() { + if (!this.rolloutData) { + this.container.innerHTML = '
No rollout data available
'; + return; + } + + const { name, version, labels } = this.rolloutData; + + // Render labels as chips + const labelsHTML = Object.entries(labels).map(([key, value]) => + `${key}: ${value}` + ).join(''); + + // Render matching nodes + const nodesHTML = this.matchingNodes.map(node => { + const nodeLabelsHTML = Object.entries(node.labels || {}).map(([key, value]) => + `${key}: ${value}` + ).join(''); + + return ` +
+
+
${this.escapeHtml(node.ip)}
+
Version: ${this.escapeHtml(node.version)}
+
+
+
+ ${nodeLabelsHTML} +
+ +
+
+ `; + }).join(''); + + this.container.innerHTML = ` +
+
+

Deploy firmware to matching cluster nodes

+
+ +
+
${this.escapeHtml(name)}
+
Version: ${this.escapeHtml(version)}
+
+ ${labelsHTML} +
+
+ +
+

Matching Nodes (${this.matchingNodes.length})

+
+ ${nodesHTML} +
+
+ +
+
+ + + + + +
+
+ Warning: This will update firmware on ${this.matchingNodes.length} node${this.matchingNodes.length !== 1 ? 's' : ''}. + The rollout process cannot be cancelled once started. +
+
+ +
+ + +
+
+ `; + + this.setupEventListeners(); + } + + handleRollout() { + if (!this.onRolloutCallback || this.matchingNodes.length === 0) { + return; + } + + const nodeCount = this.matchingNodes.length; + const nodePlural = nodeCount !== 1 ? 's' : ''; + const { name, version } = this.rolloutData; + + // Show confirmation dialog + OverlayDialogComponent.confirm({ + title: 'Confirm Firmware Rollout', + message: `Are you sure you want to deploy firmware ${this.escapeHtml(name)} version ${this.escapeHtml(version)} to ${nodeCount} node${nodePlural}?

The rollout process cannot be cancelled once started. All nodes will be updated and rebooted.`, + confirmText: `Rollout to ${nodeCount} Node${nodePlural}`, + cancelText: 'Cancel', + onConfirm: () => { + // Send the firmware info and matching nodes directly + const rolloutData = { + firmware: { + name: this.rolloutData.name, + version: this.rolloutData.version, + labels: this.rolloutData.labels + }, + nodes: this.matchingNodes + }; + + this.onRolloutCallback(rolloutData); + } + }); + } + + handleCancel() { + if (this.onCancelCallback) { + this.onCancelCallback(); + } + } + + escapeHtml(text) { + if (typeof text !== 'string') return text; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +window.RolloutComponent = RolloutComponent; diff --git a/public/styles/main.css b/public/styles/main.css index 4e12618..b96cd4c 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -79,7 +79,6 @@ p { background: var(--bg-secondary); border-radius: 16px; backdrop-filter: var(--backdrop-blur); - box-shadow: var(--shadow-primary); border: 1px solid var(--border-primary); padding: 0.75rem; margin-bottom: 1rem; @@ -701,10 +700,6 @@ p { display: flex; align-items: center; gap: 0.5rem; - padding: 0.75rem; - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: 8px; } .label-key-input, @@ -738,7 +733,7 @@ p { .labels-container { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 0.5rem; } @@ -1954,7 +1949,7 @@ p { background: rgba(244, 67, 54, 0.2); border: 1px solid rgba(244, 67, 54, 0.3); color: #ffcdd2; - padding: 1rem; + /*padding: 1rem;*/ border-radius: 8px; margin-top: 1rem; } @@ -2144,7 +2139,6 @@ p { background: var(--bg-secondary); border-radius: 16px; backdrop-filter: var(--backdrop-blur); - box-shadow: var(--shadow-primary); border: 1px solid var(--border-primary); padding: 0.75rem; margin-bottom: 1rem; @@ -2269,7 +2263,6 @@ p { .firmware-groups { display: flex; flex-direction: column; - gap: 1.5rem; } .firmware-group { @@ -2312,9 +2305,24 @@ p { display: flex; align-items: center; justify-content: space-between; + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + cursor: pointer; + position: relative; + z-index: 2; + user-select: none; +} + +.firmware-group.expanded .firmware-group-header { margin-bottom: 1rem; padding-bottom: 0.75rem; - border-bottom: 1px solid var(--border-primary); +} + +.firmware-group-header-content { + display: flex; + align-items: center; + gap: 1rem; } .firmware-group-name { @@ -2333,10 +2341,23 @@ p { font-weight: 500; } +.firmware-group-chevron { + color: var(--text-secondary); + transition: transform 0.3s ease; + flex-shrink: 0; +} + +.firmware-group.expanded .firmware-group-chevron { + transform: rotate(180deg); +} + .firmware-versions { - display: flex; + display: none; flex-direction: column; - gap: 0.75rem; +} + +.firmware-group.expanded .firmware-versions { + display: flex; } .firmware-version-item { @@ -2594,9 +2615,9 @@ p { } .action-btn.download-btn:hover { - background: rgba(34, 197, 94, 0.1); - border-color: #22c55e; - color: #22c55e; + background: rgba(96, 165, 250, 0.1); + border-color: var(--accent-secondary); + color: var(--accent-secondary); } .action-btn.delete-btn:hover { @@ -4461,7 +4482,6 @@ select.param-input:focus { #topology-graph-container { background: var(--bg-tertiary); border-radius: 12px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); height: 100%; width: 100%; margin: 0; @@ -4934,27 +4954,32 @@ select.param-input:focus { } .target-nodes-section h3 { - margin: 0 0 1.5rem 0; + margin: 0 0 0.75rem 0; font-size: 1rem; font-weight: 600; color: var(--text-primary); } .target-nodes-list { - display: flex; - flex-direction: column; - gap: 0.5rem; + overflow-y: auto; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-tertiary); } .target-node-item { + padding: 0.75rem; + border-bottom: 1px solid var(--border-primary); 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; + align-items: center; + background: transparent; + border-radius: 0; + gap: 0; +} + +.target-node-item:last-child { + border-bottom: none; } .target-node-item .node-info { @@ -4995,7 +5020,6 @@ select.param-input:focus { 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); } @@ -5028,6 +5052,15 @@ select.param-input:focus { background: rgba(244, 67, 54, 0.2); color: #f44336; border: 1px solid rgba(244, 67, 54, 0.3); + padding: 0.5rem 0.75rem !important; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + border-radius: 8px; + margin: 0; } /* Ultra-compact layout for upload progress */ @@ -6452,7 +6485,6 @@ html { background: var(--bg-secondary); border-radius: 16px; backdrop-filter: var(--backdrop-blur); - box-shadow: var(--shadow-primary); border: 1px solid var(--border-primary); padding: 1rem; margin-bottom: 1rem; @@ -6713,6 +6745,9 @@ html { .node-labels { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; margin-bottom: 0.75rem; } @@ -6733,7 +6768,7 @@ html { .label-chip { display: inline-flex; align-items: center; - font-size: 0.75rem; + font-size: 0.8rem; padding: 0.25rem 0.5rem; border-radius: 9999px; background: rgba(30, 58, 138, 0.35); @@ -7473,3 +7508,335 @@ html { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } + +.overlay-dialog-btn-danger { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + border: 1px solid #ef4444; + color: white; +} + +.overlay-dialog-btn-danger:hover { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + border-color: #dc2626; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +/* === Rollout Component Styles === */ +.rollout-btn { + background: transparent; + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 6px; + padding: 0.375rem; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.rollout-btn:hover { + background: rgba(34, 197, 94, 0.1); + border-color: #22c55e; + color: #22c55e; +} + +.rollout-btn:active { + transform: translateY(0); +} + +.rollout-panel { + padding: 1.5rem; + max-width: 600px; +} + +.rollout-header { + margin-bottom: 1.5rem; +} + +.rollout-header h3 { + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.rollout-header p { + color: var(--text-secondary); + margin-bottom: 0; +} + +.rollout-firmware-info { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + border: 1px solid var(--border-primary); +} + +.rollout-firmware-name { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.rollout-firmware-version { + color: var(--text-secondary); + margin-bottom: 0.75rem; +} + +.rollout-firmware-labels { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.rollout-matching-nodes { + margin-bottom: 1.5rem; +} + +.rollout-matching-nodes h4 { + margin-bottom: 0.75rem; + color: var(--text-primary); +} + +.rollout-nodes-list { + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-tertiary); +} + +.rollout-node-item { + padding: 0.75rem; + border-bottom: 1px solid var(--border-primary); + display: flex; + justify-content: space-between; + align-items: center; +} + +.rollout-node-item:last-child { + border-bottom: none; +} + +.rollout-node-info { + flex: 1; +} + +.rollout-node-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + min-width: 140px; + text-align: right; +} + +.rollout-node-ip { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.rollout-node-version { + font-size: 0.9rem; + color: var(--text-secondary); +} + +.rollout-node-labels { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.label-chip.small { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; +} + +.rollout-warning { + display: flex; + align-items: flex-start; + gap: 0.75rem; + background: var(--warning-bg); + border: 1px solid var(--warning-border); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.warning-icon { + color: var(--warning-color); + flex-shrink: 0; + margin-top: 0.125rem; +} + +.warning-text { + color: var(--accent-warning); + line-height: 1.5; +} + +.rollout-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding-top: 1rem; + border-top: 1px solid var(--border-primary); +} + +/* Rollout confirm button - make it look important */ +.rollout-actions .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: #41cb6d; + font-weight: 500; + padding: 0.75rem 1.25rem; + font-size: 0.9rem; + border-radius: 12px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.rollout-actions .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); +} + +.rollout-actions .deploy-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Rollout Progress Overlay */ +.rollout-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.rollout-progress-overlay { + background: var(--bg-secondary); + border-radius: 16px; + border: 1px solid var(--border-primary); + box-shadow: var(--shadow-primary); + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow: hidden; +} + +.rollout-progress-content { + padding: 2rem; +} + +.rollout-progress-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.rollout-progress-header h3 { + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.rollout-progress-body { + text-align: center; +} + +.rollout-progress-info { + margin-bottom: 1.5rem; +} + +.rollout-progress-info p { + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.rollout-progress-text { + font-weight: 600; + color: var(--text-primary); +} + +.rollout-progress-bar { + width: 100%; + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + margin-bottom: 1.5rem; +} + +.rollout-progress-fill { + height: 100%; + background: var(--accent-primary); + border-radius: 4px; + transition: width 0.3s ease; + width: 0%; +} + +.rollout-progress-details { + text-align: left; +} + +.rollout-node-list { + overflow-y: auto; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-tertiary); +} + +.rollout-node-item { + padding: 0.75rem; + border-bottom: 1px solid var(--border-primary); + display: flex; + justify-content: space-between; + align-items: center; +} + +.rollout-node-item:last-child { + border-bottom: none; +} + +.rollout-node-ip { + font-weight: 600; + color: var(--text-primary); +} + +.rollout-node-status { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 600; +} + +.rollout-node-status.status-updating_labels { + background: var(--warning-bg); + color: var(--warning-color); +} + +.rollout-node-status.status-uploading { + background: var(--info-bg); + color: var(--info-color); +} + +.rollout-node-status.status-completed { + background: var(--success-bg); + color: var(--success-color); +} + +.rollout-node-status.status-failed { + background: var(--error-bg); + color: var(--error-color); +} diff --git a/public/styles/theme.css b/public/styles/theme.css index 7ad138b..1e993cf 100644 --- a/public/styles/theme.css +++ b/public/styles/theme.css @@ -16,7 +16,7 @@ --border-secondary: rgba(255, 255, 255, 0.15); --border-hover: rgba(255, 255, 255, 0.2); - --accent-primary: #4ade80; + --accent-primary: #1d8b45; --accent-secondary: #60a5fa; --accent-warning: #fbbf24; --accent-error: #f87171;