From 7def7bce81410e063b99e3bc1d6121ebf14b9214 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Tue, 21 Oct 2025 17:51:22 +0200 Subject: [PATCH 1/2] feat: firmware registry view --- FIRMWARE_REGISTRY_INTEGRATION.md | 169 ++ public/favicon.ico | Bin 0 -> 728 bytes public/index.html | 113 +- public/scripts/api-client.js | 92 ++ public/scripts/components/ComponentsLoader.js | 2 +- .../scripts/components/FirmwareComponent.js | 1368 ++++++----------- .../components/FirmwareFormComponent.js | 350 +++++ .../components/FirmwareViewComponent.js | 21 +- public/styles/main.css | 988 +++++++++++- test/registry-integration-test.js | 212 +++ 10 files changed, 2287 insertions(+), 1028 deletions(-) create mode 100644 FIRMWARE_REGISTRY_INTEGRATION.md create mode 100644 public/favicon.ico create mode 100644 public/scripts/components/FirmwareFormComponent.js create mode 100755 test/registry-integration-test.js diff --git a/FIRMWARE_REGISTRY_INTEGRATION.md b/FIRMWARE_REGISTRY_INTEGRATION.md new file mode 100644 index 0000000..38c75d5 --- /dev/null +++ b/FIRMWARE_REGISTRY_INTEGRATION.md @@ -0,0 +1,169 @@ +# Firmware Registry Integration + +This document describes the integration of the SPORE Registry into the SPORE UI, replacing the previous firmware upload functionality with a comprehensive CRUD interface for managing firmware in the registry. + +## Overview + +The firmware view has been completely redesigned to provide: + +- **Registry Management**: Full CRUD operations for firmware in the SPORE Registry +- **Search & Filter**: Search firmware by name, version, or labels +- **Drawer Forms**: Add/edit forms displayed in the existing drawer component +- **Real-time Status**: Registry connection status indicator +- **Download Support**: Direct download of firmware binaries + +## Architecture + +### Components + +1. **FirmwareComponent** (`FirmwareComponent.js`) + - Main component for the firmware registry interface + - Handles CRUD operations and UI interactions + - Manages registry connection status + +2. **FirmwareFormComponent** (`FirmwareFormComponent.js`) + - Form component for add/edit operations + - Used within the drawer component + - Handles metadata and file uploads + +3. **API Client Extensions** (`api-client.js`) + - New registry API methods added to existing ApiClient + - Auto-detection of registry server URL + - Support for multipart form data uploads + +### API Integration + +The integration uses the SPORE Registry API endpoints: + +- `GET /health` - Health check +- `GET /firmware` - List firmware with optional filtering +- `POST /firmware` - Upload firmware with metadata +- `GET /firmware/{name}/{version}` - Download firmware binary + +### Registry Server Configuration + +The registry server is expected to run on: +- **Localhost**: `http://localhost:8080` +- **Remote**: `http://{hostname}:8080` + +The UI automatically detects the appropriate URL based on the current hostname. + +## Features + +### Firmware Management + +- **Add Firmware**: Upload new firmware with metadata and labels +- **Edit Firmware**: Modify existing firmware (requires new file upload) +- **Download Firmware**: Direct download of firmware binaries +- **Delete Firmware**: Remove firmware from registry (not yet implemented in API) + +### Search & Filtering + +- **Text Search**: Search by firmware name, version, or label values +- **Real-time Filtering**: Results update as you type +- **Label Display**: Visual display of firmware labels with color coding + +### User Interface + +- **Card Layout**: Clean card-based layout for firmware entries +- **Action Buttons**: Edit, download, and delete actions for each firmware +- **Status Indicators**: Registry connection status with visual feedback +- **Loading States**: Proper loading indicators during operations +- **Error Handling**: User-friendly error messages and notifications + +### Form Interface + +- **Drawer Integration**: Forms open in the existing drawer component +- **Metadata Fields**: Name, version, and custom labels +- **File Upload**: Drag-and-drop or click-to-upload file selection +- **Label Management**: Add/remove key-value label pairs +- **Validation**: Client-side validation with helpful error messages + +## Usage + +### Adding Firmware + +1. Click the "Add Firmware" button in the header +2. Fill in the firmware name and version +3. Select a firmware file (.bin or .hex) +4. Add optional labels (key-value pairs) +5. Click "Upload Firmware" + +### Editing Firmware + +1. Click the edit button on any firmware card +2. Modify the metadata (name and version are read-only) +3. Upload a new firmware file +4. Update labels as needed +5. Click "Update Firmware" + +### Downloading Firmware + +1. Click the download button on any firmware card +2. The firmware binary will be downloaded automatically + +### Searching Firmware + +1. Use the search box to filter firmware +2. Search by name, version, or label values +3. Results update in real-time + +## Testing + +A test suite is provided to verify the registry integration: + +```bash +cd spore-ui/test +node registry-integration-test.js +``` + +The test suite verifies: +- Registry health check +- List firmware functionality +- Upload firmware functionality +- Download firmware functionality + +## Configuration + +### Registry Server + +Ensure the SPORE Registry server is running on port 8080: + +```bash +cd spore-registry +go run main.go +``` + +### UI Configuration + +The UI automatically detects the registry server URL. No additional configuration is required. + +## Error Handling + +The integration includes comprehensive error handling: + +- **Connection Errors**: Clear indication when registry is unavailable +- **Upload Errors**: Detailed error messages for upload failures +- **Validation Errors**: Client-side validation with helpful messages +- **Network Errors**: Graceful handling of network timeouts and failures + +## Future Enhancements + +Planned improvements include: + +- **Delete Functionality**: Complete delete operation when API supports it +- **Bulk Operations**: Select multiple firmware for bulk operations +- **Version History**: View and manage firmware version history +- **Deployment Integration**: Deploy firmware directly to nodes from registry +- **Advanced Filtering**: Filter by date, size, or other metadata + +## Migration Notes + +The previous firmware upload functionality has been completely replaced. The new interface provides: + +- Better organization with the registry +- Improved user experience with search and filtering +- Consistent UI patterns with the rest of the application +- Better error handling and user feedback + +All existing firmware functionality is now handled through the registry interface. diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1658e25fc3c6e635e0d42665a7acbbc8e6338f51 GIT binary patch literal 728 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf2?- zG7x6;t5)LxG9*h}BT9nv(@M${i&7cN%ggmL^RkPR6AM!H@{7`Ezq65IU|@{!ba4!^ z=v_M5)|=T;plyCL3-`f8PSVlb(%hqHxQ2j4#EhGgkuz^eMa{Y`9XzB4zvJLX<=#O&Kr5vS+eoq3)csOUTZC53uT literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index 4ab9d37..73cbed0 100644 --- a/public/index.html +++ b/public/index.html @@ -147,77 +147,51 @@
-
-
-
-
-

- - - - - Firmware Update -

-
-
-
-
- - - -
- -
- - - No file selected -
-
- - -
-
-
+
+ - -
- +
+
+ + + + + + + Registry Disconnected + +
+ + +
+
+ +
+
+
+
+
Loading firmware...
+
@@ -272,6 +246,7 @@ + diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index fa0a458..3f9e6ed 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -136,6 +136,98 @@ 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`; + } + } + + 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`, { + method: 'POST', + body: formData + }); + + 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)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(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(); + } + + async downloadFirmwareFromRegistry(name, version) { + const registryBaseUrl = await this.getRegistryBaseUrl(); + const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Registry download failed: ${errorText}`); + } + + 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(); + } } // Global API client instance diff --git a/public/scripts/components/ComponentsLoader.js b/public/scripts/components/ComponentsLoader.js index f294a9e..9c441f5 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 && window.WiFiConfigComponent); + return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.FirmwareFormComponent && 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/FirmwareComponent.js b/public/scripts/components/FirmwareComponent.js index 2352603..be55d50 100644 --- a/public/scripts/components/FirmwareComponent.js +++ b/public/scripts/components/FirmwareComponent.js @@ -1,109 +1,44 @@ -// Firmware Component +// Registry Firmware Component - CRUD interface for firmware registry class FirmwareComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); logger.debug('FirmwareComponent: Constructor called'); logger.debug('FirmwareComponent: Container:', container); - logger.debug('FirmwareComponent: Container ID:', container?.id); - // Initialize overlay dialog - this.overlayDialog = null; + // Initialize drawer component + this.drawer = new DrawerComponent(); - // Check if the dropdown exists in the container - if (container) { - const dropdown = container.querySelector('#specific-node-select'); - logger.debug('FirmwareComponent: Dropdown found in constructor:', !!dropdown); - if (dropdown) { - logger.debug('FirmwareComponent: Dropdown tagName:', dropdown.tagName); - logger.debug('FirmwareComponent: Dropdown id:', dropdown.id); - } - } + // Registry connection status + this.registryConnected = false; + this.registryError = null; } setupEventListeners() { - // Setup global firmware file input - const globalFirmwareFile = this.findElement('#global-firmware-file'); - if (globalFirmwareFile) { - this.addEventListener(globalFirmwareFile, 'change', this.handleFileSelect.bind(this)); + // Setup refresh button + const refreshBtn = this.findElement('#refresh-firmware-btn'); + if (refreshBtn) { + this.addEventListener(refreshBtn, 'click', this.refreshFirmwareList.bind(this)); } - // Setup target selection - const targetRadios = this.findAllElements('input[name="target-type"]'); - targetRadios.forEach(radio => { - this.addEventListener(radio, 'change', this.handleTargetChange.bind(this)); - }); + // Setup add firmware button + const addBtn = this.findElement('#add-firmware-btn'); + if (addBtn) { + this.addEventListener(addBtn, 'click', this.showAddFirmwareForm.bind(this)); + } - // Setup WebSocket listener for real-time firmware upload status - this.setupWebSocketListeners(); - - // Setup specific node select change handler - const specificNodeSelect = this.findElement('#specific-node-select'); - logger.debug('FirmwareComponent: setupEventListeners - specificNodeSelect found:', !!specificNodeSelect); - if (specificNodeSelect) { - logger.debug('FirmwareComponent: specificNodeSelect element:', specificNodeSelect); - logger.debug('FirmwareComponent: specificNodeSelect tagName:', specificNodeSelect.tagName); - logger.debug('FirmwareComponent: specificNodeSelect id:', specificNodeSelect.id); - - // Store the bound handler as an instance property - this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); - this.addEventListener(specificNodeSelect, 'change', this._boundNodeSelectHandler); - logger.debug('FirmwareComponent: Event listener added to specificNodeSelect'); - } else { - logger.warn('FirmwareComponent: specificNodeSelect element not found during setupEventListeners'); - } - - // Setup label select change handler (single-select add-to-chips) - const labelSelect = this.findElement('#label-select'); - if (labelSelect) { - this._boundLabelSelectHandler = (e) => { - const value = e.target.value; - if (!value) return; - const current = this.viewModel.get('selectedLabels') || []; - if (!current.includes(value)) { - this.viewModel.setSelectedLabels([...current, value]); - } - // Reset select back to placeholder - e.target.value = ''; - this.renderSelectedLabelChips(); - this.updateAffectedNodesPreview(); - this.updateDeployButton(); - }; - this.addEventListener(labelSelect, 'change', this._boundLabelSelectHandler); - } - - // Setup deploy button - const deployBtn = this.findElement('#deploy-btn'); - if (deployBtn) { - this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this)); + // Setup search input + const searchInput = this.findElement('#firmware-search'); + if (searchInput) { + this.addEventListener(searchInput, 'input', this.handleSearch.bind(this)); } } setupViewModelListeners() { - this.subscribeToProperty('selectedFile', () => { - this.updateFileInfo(); - this.updateDeployButton(); - }); - this.subscribeToProperty('targetType', () => { - this.updateTargetVisibility(); - this.updateDeployButton(); - this.updateAffectedNodesPreview(); - }); - this.subscribeToProperty('specificNode', this.updateDeployButton.bind(this)); - this.subscribeToProperty('availableNodes', () => { - this.populateNodeSelect(); - this.populateLabelSelect(); - this.updateDeployButton(); - this.updateAffectedNodesPreview(); - }); - this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this)); - this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this)); - this.subscribeToProperty('isUploading', this.updateUploadState.bind(this)); - this.subscribeToProperty('selectedLabels', () => { - this.populateLabelSelect(); - this.updateAffectedNodesPreview(); - this.updateDeployButton(); - }); + this.subscribeToProperty('firmwareList', this.renderFirmwareList.bind(this)); + this.subscribeToProperty('isLoading', this.updateLoadingState.bind(this)); + this.subscribeToProperty('searchQuery', this.updateSearchResults.bind(this)); + this.subscribeToProperty('registryConnected', this.updateRegistryStatus.bind(this)); } mount() { @@ -111,897 +46,482 @@ 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); - if (dropdown) { - logger.debug('FirmwareComponent: Mount - dropdown tagName:', dropdown.tagName); - logger.debug('FirmwareComponent: Mount - dropdown id:', dropdown.id); - logger.debug('FirmwareComponent: Mount - dropdown innerHTML:', dropdown.innerHTML); - } - - // Initialize target visibility and label list on first mount - try { - this.updateTargetVisibility(); - this.populateLabelSelect(); - this.updateAffectedNodesPreview(); - } catch (e) { - logger.warn('FirmwareComponent: Initialization after mount failed:', e); - } + // Check registry connection and load firmware list + this.checkRegistryConnection(); + this.loadFirmwareList(); logger.debug('FirmwareComponent: Mounted successfully'); } - render() { - // Initial render is handled by the HTML template - 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(); + async checkRegistryConnection() { + try { + await window.apiClient.getRegistryHealth(); + this.registryConnected = true; + this.registryError = null; + this.viewModel.set('registryConnected', true); + } catch (error) { + logger.error('Registry connection failed:', error); + this.registryConnected = false; + this.registryError = error.message; + this.viewModel.set('registryConnected', false); } } - handleFileSelect(event) { - const file = event.target.files[0]; - this.viewModel.setSelectedFile(file); + async loadFirmwareList() { + try { + this.viewModel.set('isLoading', true); + const firmwareList = await window.apiClient.listFirmwareFromRegistry(); + this.viewModel.set('firmwareList', firmwareList); + } catch (error) { + logger.error('Failed to load firmware list:', error); + this.viewModel.set('firmwareList', []); + this.showError('Failed to load firmware list: ' + error.message); + } finally { + this.viewModel.set('isLoading', false); + } } - handleTargetChange(event) { - const targetType = event.target.value; - this.viewModel.setTargetType(targetType); + async refreshFirmwareList() { + await this.checkRegistryConnection(); + await this.loadFirmwareList(); } - handleNodeSelect(event) { - const nodeIp = event.target.value; - logger.debug('FirmwareComponent: handleNodeSelect called with nodeIp:', nodeIp); - logger.debug('Event:', event); - logger.debug('Event target:', event.target); - logger.debug('Event target value:', event.target.value); - - this.viewModel.setSpecificNode(nodeIp); - - // Also update the deploy button state - this.updateDeployButton(); - } + renderFirmwareList() { + const container = this.findElement('#firmware-list-container'); + if (!container) return; - async handleDeploy() { - const file = this.viewModel.get('selectedFile'); - const targetType = this.viewModel.get('targetType'); - const specificNode = this.viewModel.get('specificNode'); + const groupedFirmware = this.viewModel.get('firmwareList') || []; + const searchQuery = this.viewModel.get('searchQuery') || ''; - if (!file) { - this.showConfirmationDialog({ - title: 'No File Selected', - message: 'Please select a firmware file first.', - confirmText: 'OK', - cancelText: null, - onConfirm: () => {}, - onCancel: null + // Filter grouped firmware based on search query + const filteredGroups = groupedFirmware.map(group => { + if (!searchQuery) return group; + + // Split search query into individual terms + const searchTerms = searchQuery.toLowerCase().split(/\s+/).filter(term => term.length > 0); + + // Filter firmware versions within the group + const filteredFirmware = group.firmware.filter(firmware => { + // All search terms must match somewhere in the firmware data + return searchTerms.every(term => { + // Check group name + if (group.name.toLowerCase().includes(term)) { + return true; + } + + // Check version + if (firmware.version.toLowerCase().includes(term)) { + return true; + } + + // Check labels + if (Object.values(firmware.labels || {}).some(label => + label.toLowerCase().includes(term) + )) { + return true; + } + + return false; + }); }); + + // Return group with filtered firmware, or null if no firmware matches + return filteredFirmware.length > 0 ? { + ...group, + firmware: filteredFirmware + } : null; + }).filter(group => group !== null); + + if (filteredGroups.length === 0) { + container.innerHTML = ` +
+
+ + + + +
+
${searchQuery ? 'No firmware found' : 'No firmware available'}
+
+ ${searchQuery ? 'Try adjusting your search terms' : 'Upload your first firmware to get started'} +
+
+ `; return; } + + const firmwareHTML = filteredGroups.map(group => this.renderFirmwareGroup(group)).join(''); - if (targetType === 'specific' && !specificNode) { - this.showConfirmationDialog({ - title: 'No Node Selected', - message: 'Please select a specific node to update.', - confirmText: 'OK', - cancelText: null, - onConfirm: () => {}, - onCancel: null + container.innerHTML = ` +
+ ${firmwareHTML} +
+ `; + + // Setup event listeners for firmware items + this.setupFirmwareItemListeners(); + } + + renderFirmwareGroup(group) { + const versionsHTML = group.firmware.map(firmware => this.renderFirmwareVersion(firmware)).join(''); + + return ` +
+
+

${this.escapeHtml(group.name)}

+ ${group.firmware.length} version${group.firmware.length !== 1 ? 's' : ''} +
+
+ ${versionsHTML} +
+
+ `; + } + + renderFirmwareVersion(firmware) { + const labels = firmware.labels || {}; + const labelsHTML = Object.entries(labels).map(([key, value]) => + `${key}: ${value}` + ).join(''); + + const sizeKB = Math.round(firmware.size / 1024); + + return ` +
+
+
+
v${this.escapeHtml(firmware.version)}
+
${sizeKB} KB
+
+
+ ${labelsHTML} +
+
+
+ + +
+
+ `; + } + + renderFirmwareItem(firmware) { + const labels = firmware.labels || {}; + const labelsHTML = Object.entries(labels).map(([key, value]) => + `${key}: ${value}` + ).join(''); + + const sizeKB = Math.round(firmware.size / 1024); + + return ` +
+
+
+
${this.escapeHtml(firmware.name)}
+
v${this.escapeHtml(firmware.version)}
+
${sizeKB} KB
+
+
+ ${labelsHTML} +
+
+
+ + + +
+
+ `; + } + + setupFirmwareItemListeners() { + // Version item clicks (for editing) + const versionItems = this.findAllElements('.firmware-version-item.clickable'); + versionItems.forEach(item => { + this.addEventListener(item, 'click', (e) => { + // Don't trigger if clicking on action buttons + if (e.target.closest('.firmware-version-actions')) { + return; + } + + const name = item.getAttribute('data-name'); + const version = item.getAttribute('data-version'); + this.showEditFirmwareForm(name, version); }); - return; - } - - // Show confirmation dialog for deployment - this.showDeploymentConfirmation(file, targetType, specificNode); + }); + + // Download buttons + const downloadBtns = this.findAllElements('.download-btn'); + downloadBtns.forEach(btn => { + this.addEventListener(btn, 'click', (e) => { + e.stopPropagation(); + const name = btn.getAttribute('data-name'); + const version = btn.getAttribute('data-version'); + this.downloadFirmware(name, version); + }); + }); + + // Delete buttons + const deleteBtns = this.findAllElements('.delete-btn'); + deleteBtns.forEach(btn => { + this.addEventListener(btn, 'click', (e) => { + e.stopPropagation(); + const name = btn.getAttribute('data-name'); + const version = btn.getAttribute('data-version'); + this.showDeleteConfirmation(name, version); + }); + }); } - showConfirmationDialog(options) { - if (!this.overlayDialog) { - this.initializeOverlayDialog(); - } - - this.overlayDialog.show(options); + showAddFirmwareForm() { + this.openFirmwareForm('Add Firmware', null, null); } - showDeploymentConfirmation(file, targetType, specificNode) { - let title, message; + showEditFirmwareForm(name, version) { + const groupedFirmware = this.viewModel.get('firmwareList') || []; - 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('
')}`; + // Find the firmware in the grouped data + let firmware = null; + for (const group of groupedFirmware) { + if (group.name === name) { + firmware = group.firmware.find(f => f.version === version); + if (firmware) break; + } } + if (firmware) { + this.openFirmwareForm('Edit Firmware', firmware, null); + } + } + + openFirmwareForm(title, firmwareData, onCloseCallback) { + this.drawer.openDrawer(title, (contentContainer, setActiveComponent) => { + const formComponent = new FirmwareFormComponent(contentContainer, this.viewModel, this.eventBus); + setActiveComponent(formComponent); + + formComponent.setFirmwareData(firmwareData); + formComponent.setOnSaveCallback(() => { + this.loadFirmwareList(); + this.drawer.closeDrawer(); + }); + formComponent.setOnCancelCallback(() => { + this.drawer.closeDrawer(); + }); + + formComponent.mount(); + }, null, onCloseCallback, true); // Hide terminal button + } + + async downloadFirmware(name, version) { + try { + const blob = await window.apiClient.downloadFirmwareFromRegistry(name, version); + + // Create download link + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${name}-${version}.bin`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + this.showSuccess(`Firmware ${name} v${version} downloaded successfully`); + } catch (error) { + logger.error('Download failed:', error); + this.showError('Download failed: ' + error.message); + } + } + + showDeleteConfirmation(name, version) { this.showConfirmationDialog({ - title: title, - message: message, - confirmText: 'Deploy', + title: 'Delete Firmware', + message: `Are you sure you want to delete firmware "${name}" version "${version}"?

This action cannot be undone.`, + confirmText: 'Delete', cancelText: 'Cancel', - onConfirm: () => this.performDeployment(file, targetType, specificNode), + onConfirm: () => this.deleteFirmware(name, version), onCancel: () => {} }); } - async performDeployment(file, targetType, specificNode) { + async deleteFirmware(name, version) { try { - this.viewModel.startUpload(); - - if (targetType === 'all') { - await this.uploadToAllNodes(file); - } else if (targetType === 'specific') { - await this.uploadToSpecificNode(file, specificNode); - } else if (targetType === 'labels') { - await this.uploadToLabelFilteredNodes(file); - } - - // NOTE: Don't reset upload state here! - // The upload state should remain active until websocket confirms completion - // Status updates and finalization happen via websocket messages in checkAndFinalizeUploadResults() - logger.debug('Firmware upload HTTP requests completed, waiting for websocket status updates'); - + // Note: The registry API doesn't have a delete endpoint in the OpenAPI spec + // This would need to be implemented in the registry service + this.showError('Delete functionality not yet implemented in registry API'); } catch (error) { - logger.error('Firmware deployment failed:', error); - this.showConfirmationDialog({ - title: 'Deployment Failed', - message: `Deployment failed: ${error.message}`, - confirmText: 'OK', - cancelText: null, - onConfirm: () => {}, - onCancel: null - }); - // Only complete upload on error - this.viewModel.completeUpload(); + logger.error('Delete failed:', error); + this.showError('Delete failed: ' + error.message); } } - async uploadToAllNodes(file) { - try { - // Get current cluster members - const response = await window.apiClient.getClusterMembers(); - const nodes = response.members || []; - - if (nodes.length === 0) { - this.showConfirmationDialog({ - title: 'No Nodes Available', - message: 'No nodes available for firmware update.', - confirmText: 'OK', - cancelText: null, - onConfirm: () => {}, - onCancel: null - }); - return; - } - - // Show upload progress area - this.showUploadProgress(file, nodes); - - // Start batch upload - const results = await this.performBatchUpload(file, nodes); - - // Don't display results here - wait for websocket to confirm all uploads complete - logger.debug('Batch upload HTTP requests completed, waiting for websocket confirmations'); - - } catch (error) { - logger.error('Failed to upload firmware to all nodes:', error); - throw error; - } + handleSearch(event) { + const query = event.target.value; + this.viewModel.set('searchQuery', query); } - async uploadToSpecificNode(file, nodeIp) { - try { - // Show upload progress area - this.showUploadProgress(file, [{ ip: nodeIp, hostname: nodeIp }]); - - // Note: Status updates will come via websocket messages - // We don't update progress here as the HTTP response is just an acknowledgment - - // Perform single node upload (this sends the file and gets acknowledgment) - const result = await this.performSingleUpload(file, nodeIp); - - // Don't immediately mark as completed - wait for websocket status updates - logger.debug(`Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); - - } catch (error) { - logger.error(`Failed to upload firmware to node ${nodeIp}:`, error); - - // For HTTP errors, we can immediately mark as failed since the upload didn't start - this.updateNodeProgress(1, 1, nodeIp, 'Failed'); - this.updateOverallProgress(0, 1); - - // Display error results - const errorResult = { - nodeIp: nodeIp, - hostname: nodeIp, - success: false, - error: error.message, - timestamp: new Date().toISOString() - }; - this.displayUploadResults([errorResult]); - - throw error; - } + updateSearchResults() { + // This method is called when searchQuery property changes + // The actual filtering is handled in renderFirmwareList + this.renderFirmwareList(); } - async uploadToLabelFilteredNodes(file) { - try { - const nodes = this.viewModel.getAffectedNodesByLabels(); - if (!nodes || nodes.length === 0) { - this.showConfirmationDialog({ - title: 'No Matching Nodes', - message: 'No nodes match the selected labels.', - confirmText: 'OK', - cancelText: null, - onConfirm: () => {}, - onCancel: null - }); - return; - } - - // Show upload progress area - this.showUploadProgress(file, nodes); - - // Start batch upload - const results = await this.performBatchUpload(file, nodes); - - // Don't display results here - wait for websocket to confirm all uploads complete - logger.debug('Label-filtered upload HTTP requests completed, waiting for websocket confirmations'); - } catch (error) { - logger.error('Failed to upload firmware to label-filtered nodes:', error); - throw error; - } - } - - async performBatchUpload(file, nodes) { - const results = []; - const totalNodes = nodes.length; - let successfulUploads = 0; - - // Initialize all nodes as uploading first - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - const nodeIp = node.ip; - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...'); - } - - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - const nodeIp = node.ip; - - try { - // Upload to this node (HTTP call just initiates the upload) - const result = await this.performSingleUpload(file, nodeIp); - - // Don't immediately mark as completed - wait for websocket status - logger.debug(`Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`); - results.push(result); - - } 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); - - // For HTTP errors, we can immediately mark as failed since the upload didn't start - this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed'); - } - - // 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); - - // IMPORTANT: This HTTP response is just an acknowledgment that the gateway received the file - // The actual firmware processing happens asynchronously on the device - // Status updates will come via WebSocket messages, NOT from this HTTP response - logger.debug(`HTTP acknowledgment received for ${nodeIp}:`, result); - logger.debug(`This does NOT mean upload is complete - waiting for WebSocket status updates`); - - return { - nodeIp: nodeIp, - hostname: nodeIp, - httpAcknowledged: true, // Changed from 'success' to make it clear this is just HTTP ack - result: result, - timestamp: new Date().toISOString() - }; - - } catch (error) { - throw new Error(`Upload to ${nodeIp} failed: ${error.message}`); - } - } - - showUploadProgress(file, nodes) { - const container = this.findElement('#firmware-nodes-list'); + updateLoadingState() { + const isLoading = this.viewModel.get('isLoading'); + const container = this.findElement('#firmware-list-container'); - const progressHTML = ` -
-
-

- - - - - - Firmware Upload Progress -

-
- File: ${file.name} - Size: ${(file.size / 1024).toFixed(1)}KB - Targets: ${nodes.length} node(s) -
-
-
-
-
- 0/${nodes.length} Successful (0%) -
-
- Status: Upload in progress... -
+ if (isLoading && container) { + container.innerHTML = ` +
+
+
Loading firmware...
-
- ${nodes.map(node => ` -
-
- ${node.hostname || node.ip} - ${node.ip} -
-
Uploading...
-
-
- `).join('')} + `; + } + } + + updateRegistryStatus() { + const isConnected = this.viewModel.get('registryConnected'); + const statusElement = this.findElement('#registry-status'); + + if (statusElement) { + if (isConnected) { + statusElement.innerHTML = ` + + + + + + Registry Connected + + `; + } else { + statusElement.innerHTML = ` + + + + + + + Registry Disconnected + + `; + } + } + } + + showConfirmationDialog(options) { + // Create a simple confirmation dialog + const overlay = document.createElement('div'); + overlay.className = 'overlay-dialog'; + overlay.innerHTML = ` +
+
+

${options.title}

+
+
+

${options.message}

+
+
`; - container.innerHTML = progressHTML; + document.body.appendChild(overlay); - // Initialize progress for single-node uploads - if (nodes.length === 1) { - const node = nodes[0]; - this.updateNodeProgress(1, 1, node.ip, 'Uploading...'); - } - } - - updateNodeProgress(current, total, nodeIp, status) { - const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`); - if (progressItem) { - const statusElement = progressItem.querySelector('.progress-status'); - const timeElement = progressItem.querySelector('.progress-time'); - - if (statusElement) { - statusElement.textContent = status; - - // Add status-specific styling - statusElement.className = 'progress-status'; - if (status === 'Completed') { - statusElement.classList.add('success'); - if (timeElement) { - timeElement.textContent = new Date().toLocaleTimeString(); - } - } else if (status === 'Failed') { - statusElement.classList.add('error'); - if (timeElement) { - timeElement.textContent = new Date().toLocaleTimeString(); - } - } else if (status === 'Uploading...') { - statusElement.classList.add('uploading'); - if (timeElement) { - timeElement.textContent = 'Started: ' + new Date().toLocaleTimeString(); - } - } - } - } - } - - updateOverallProgress(successfulUploads, totalNodes) { - const progressBar = this.findElement('#overall-progress-bar'); - const progressText = this.findElement('.progress-text'); + const confirmBtn = overlay.querySelector('#confirm-btn'); + const cancelBtn = overlay.querySelector('#cancel-btn'); - 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'; - } - - // NOTE: Don't update progress summary here for single-node uploads - // The summary should only be updated via websocket status updates - // This prevents premature "completed successfully" messages - } - } - - 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'); - const deployBtn = this.findElement('#deploy-btn'); - - 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(); - } - - updateTargetVisibility() { - const targetType = this.viewModel.get('targetType'); - const specificNodeSelect = this.findElement('#specific-node-select'); - const labelSelect = this.findElement('#label-select'); - - logger.debug('FirmwareComponent: updateTargetVisibility called with targetType:', targetType); - - if (targetType === 'specific') { - if (specificNodeSelect) { specificNodeSelect.style.display = 'inline-block'; } - if (labelSelect) { labelSelect.style.display = 'none'; } - this.populateNodeSelect(); - } else if (targetType === 'labels') { - if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } - if (labelSelect) { - labelSelect.style.display = 'inline-block'; - this.populateLabelSelect(); - } - } else { - if (specificNodeSelect) { specificNodeSelect.style.display = 'none'; } - if (labelSelect) { labelSelect.style.display = 'none'; } - } - this.updateDeployButton(); - } - - // Note: handleNodeSelect is already defined above and handles the actual node selection - // This duplicate method was causing the issue - removing it - - updateDeployButton() { - const deployBtn = this.findElement('#deploy-btn'); - if (deployBtn) { - deployBtn.disabled = !this.viewModel.isDeployEnabled(); - } - } - - setupWebSocketListeners() { - // Listen for real-time firmware upload status updates - window.wsClient.on('firmwareUploadStatus', (data) => { - this.handleFirmwareUploadStatus(data); + confirmBtn.addEventListener('click', () => { + document.body.removeChild(overlay); + if (options.onConfirm) options.onConfirm(); }); - } - - handleFirmwareUploadStatus(data) { - const { nodeIp, status, filename, fileSize, timestamp } = data; - - logger.debug('Firmware upload status received:', { nodeIp, status, filename, timestamp: new Date(timestamp).toLocaleTimeString() }); - - // Check if there's currently an upload in progress - const isUploading = this.viewModel.get('isUploading'); - if (!isUploading) { - logger.debug('No active upload, ignoring status update'); - return; - } - - // Find the progress item for this node - const progressItem = this.findElement(`[data-node-ip="${nodeIp}"]`); - if (!progressItem) { - logger.debug('No progress item found for node:', nodeIp); - return; - } - - // Update the status display based on the received status - const statusElement = progressItem.querySelector('.progress-status'); - const timeElement = progressItem.querySelector('.progress-time'); - - if (statusElement) { - let displayStatus = status; - let statusClass = ''; - - logger.debug(`Updating status for node ${nodeIp}: ${status} -> ${displayStatus}`); - - switch (status) { - case 'uploading': - displayStatus = 'Uploading...'; - statusClass = 'uploading'; - break; - case 'completed': - displayStatus = 'Completed'; - statusClass = 'success'; - logger.debug(`Node ${nodeIp} marked as completed`); - break; - case 'failed': - displayStatus = 'Failed'; - statusClass = 'error'; - break; - default: - displayStatus = status; - break; - } - - statusElement.textContent = displayStatus; - statusElement.className = `progress-status ${statusClass}`; - - // Update timestamp for completed/failed uploads - if ((status === 'completed' || status === 'failed') && timeElement) { - timeElement.textContent = new Date(timestamp).toLocaleTimeString(); - } else if (status === 'uploading' && timeElement) { - timeElement.textContent = 'Started: ' + new Date(timestamp).toLocaleTimeString(); - } - } - - // Update overall progress if we have multiple nodes - this.updateOverallProgressFromStatus(); - - // Check if all uploads are complete and finalize results - this.checkAndFinalizeUploadResults(); - } - - checkAndFinalizeUploadResults() { - const progressItems = this.findAllElements('.progress-item'); - if (progressItems.length === 0) return; - - // Check if all uploads are complete (either completed or failed) - let allComplete = true; - let hasAnyCompleted = false; - let hasAnyFailed = false; - let uploadingCount = 0; - - const statuses = []; - progressItems.forEach(item => { - const statusElement = item.querySelector('.progress-status'); - if (statusElement) { - const status = statusElement.textContent; - statuses.push(status); - - if (status !== 'Completed' && status !== 'Failed') { - allComplete = false; - if (status === 'Uploading...') { - uploadingCount++; - } - } - if (status === 'Completed') { - hasAnyCompleted = true; - } - if (status === 'Failed') { - hasAnyFailed = true; - } - } - }); - - logger.debug('Upload status check:', { - totalItems: progressItems.length, - allComplete, - uploadingCount, - hasAnyCompleted, - hasAnyFailed, - statuses - }); - - // If all uploads are complete, finalize the results - if (allComplete) { - logger.debug('All firmware uploads complete, finalizing results'); - - // Generate results based on current status - const results = progressItems.map(item => { - const nodeIp = item.getAttribute('data-node-ip'); - const nodeName = item.querySelector('.node-name')?.textContent || nodeIp; - const statusElement = item.querySelector('.progress-status'); - const status = statusElement?.textContent || 'Unknown'; - - return { - nodeIp: nodeIp, - hostname: nodeName, - success: status === 'Completed', - error: status === 'Failed' ? 'Upload failed' : undefined, - timestamp: new Date().toISOString() - }; + + 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); + } - // Update the header and summary to show final results - this.displayUploadResults(results); - - // Now that all uploads are truly complete (confirmed via websocket), mark upload as complete - this.viewModel.completeUpload(); - - // Reset upload state after a short delay to allow user to see results + showSuccess(message) { + this.showNotification(message, 'success'); + } + + showError(message) { + this.showNotification(message, 'error'); + } + + showNotification(message, type = 'info') { + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + notification.textContent = message; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.classList.add('show'); + }, 100); + + setTimeout(() => { + notification.classList.remove('show'); setTimeout(() => { - this.viewModel.resetUploadState(); - }, 5000); - } else if (uploadingCount > 0) { - logger.debug(`${uploadingCount} uploads still in progress, not finalizing yet`); - } else { - logger.debug('Some uploads may have unknown status, but not finalizing yet'); - } - } - - updateOverallProgressFromStatus() { - const progressItems = this.findAllElements('.progress-item'); - if (progressItems.length <= 1) { - return; // Only update for multi-node uploads - } - - let completedCount = 0; - let failedCount = 0; - let uploadingCount = 0; - - progressItems.forEach(item => { - const statusElement = item.querySelector('.progress-status'); - if (statusElement) { - const status = statusElement.textContent; - if (status === 'Completed') { - completedCount++; - } else if (status === 'Failed') { - failedCount++; - } else if (status === 'Uploading...') { - uploadingCount++; + if (notification.parentNode) { + document.body.removeChild(notification); } - } - }); - - const totalNodes = progressItems.length; - const successfulUploads = completedCount; - const successPercentage = Math.round((successfulUploads / totalNodes) * 100); - - // Update overall progress bar - const progressBar = this.findElement('#overall-progress-bar'); - const progressText = this.findElement('.progress-text'); - - if (progressBar && progressText) { - progressBar.style.width = `${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'; - } - - progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`; - } - - // Update progress summary - const progressSummary = this.findElement('#progress-summary'); - if (progressSummary) { - if (failedCount > 0) { - progressSummary.innerHTML = `${window.icon('warning', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading, ${failedCount} failed)`; - } else if (uploadingCount > 0) { - progressSummary.innerHTML = `${window.icon('info', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading)`; - } else if (completedCount === totalNodes) { - progressSummary.innerHTML = `${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}`; - } - } + }, 300); + }, 3000); } - populateNodeSelect() { - const select = this.findElement('#specific-node-select'); - if (!select) { - logger.warn('FirmwareComponent: populateNodeSelect - select element not found'); - return; - } - - if (select.tagName !== 'SELECT') { - logger.warn('FirmwareComponent: populateNodeSelect - element is not a SELECT:', select.tagName); - return; - } - - logger.debug('FirmwareComponent: populateNodeSelect called'); - logger.debug('FirmwareComponent: Select element:', select); - logger.debug('FirmwareComponent: Available nodes:', this.viewModel.get('availableNodes')); - - // Clear existing options - select.innerHTML = ''; - - // Get available nodes from the view model - const availableNodes = this.viewModel.get('availableNodes'); - - if (!availableNodes || availableNodes.length === 0) { - // No nodes available - const option = document.createElement('option'); - option.value = ""; - option.textContent = "No nodes available"; - option.disabled = true; - select.appendChild(option); - return; - } - - availableNodes.forEach(node => { - const option = document.createElement('option'); - option.value = node.ip; - option.textContent = `${node.hostname} (${node.ip})`; - select.appendChild(option); - }); - - // Ensure event listener is still bound after repopulating - this.ensureNodeSelectListener(select); - - logger.debug('FirmwareComponent: Node select populated with', availableNodes.length, 'nodes'); - } - - // Ensure the node select change listener is properly bound - ensureNodeSelectListener(select) { - if (!select) return; - - // Store the bound handler as an instance property to avoid binding issues - if (!this._boundNodeSelectHandler) { - this._boundNodeSelectHandler = this.handleNodeSelect.bind(this); - } - - // Remove any existing listeners and add the bound one - select.removeEventListener('change', this._boundNodeSelectHandler); - select.addEventListener('change', this._boundNodeSelectHandler); - - logger.debug('FirmwareComponent: Node select event listener ensured'); - } - - updateUploadProgress() { - // This will be implemented when we add upload progress tracking - } - - updateUploadResults() { - // This will be implemented when we add upload results display - } - - updateUploadState() { - const isUploading = this.viewModel.get('isUploading'); - const deployBtn = this.findElement('#deploy-btn'); - - if (deployBtn) { - deployBtn.disabled = isUploading; - if (isUploading) { - deployBtn.classList.add('loading'); - deployBtn.textContent = '⏳ Deploying...'; - } else { - deployBtn.classList.remove('loading'); - deployBtn.textContent = '🚀 Deploy'; - } - } - - this.updateDeployButton(); - } - - populateLabelSelect() { - const select = this.findElement('#label-select'); - if (!select) return; - const labels = this.viewModel.get('availableLabels') || []; - const selected = new Set(this.viewModel.get('selectedLabels') || []); - const options = [''] - .concat(labels.filter(l => !selected.has(l)).map(l => ``)); - select.innerHTML = options.join(''); - // Ensure change listener remains bound - if (this._boundLabelSelectHandler) { - select.removeEventListener('change', this._boundLabelSelectHandler); - select.addEventListener('change', this._boundLabelSelectHandler); - } - this.renderSelectedLabelChips(); - } - - renderSelectedLabelChips() { - const container = this.findElement('#selected-labels-container'); - if (!container) return; - const selected = this.viewModel.get('selectedLabels') || []; - if (selected.length === 0) { - container.innerHTML = ''; - return; - } - container.innerHTML = selected.map(l => ` - - ${l} - - - `).join(''); - Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => { - this.addEventListener(btn, 'click', (e) => { - e.stopPropagation(); - const label = btn.getAttribute('data-label'); - const current = this.viewModel.get('selectedLabels') || []; - this.viewModel.setSelectedLabels(current.filter(x => x !== label)); - this.populateLabelSelect(); - this.updateAffectedNodesPreview(); - this.updateDeployButton(); - }); - }); - } - - updateAffectedNodesPreview() { - const container = this.findElement('#firmware-nodes-list'); - if (!container) return; - if (this.viewModel.get('targetType') !== 'labels') { - container.innerHTML = ''; - return; - } - const nodes = this.viewModel.getAffectedNodesByLabels(); - if (!nodes.length) { - container.innerHTML = `
No nodes match the selected labels
`; - return; - } - const html = ` -
-

Affected Nodes (${nodes.length})

-
- ${nodes.map(n => ` -
-
${n.hostname || n.ip}${n.ip}
-
- `).join('')} -
-
`; - container.innerHTML = html; + escapeHtml(text) { + if (typeof text !== 'string') return text; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } } diff --git a/public/scripts/components/FirmwareFormComponent.js b/public/scripts/components/FirmwareFormComponent.js new file mode 100644 index 0000000..5787417 --- /dev/null +++ b/public/scripts/components/FirmwareFormComponent.js @@ -0,0 +1,350 @@ +// Firmware Form Component for add/edit operations in drawer +class FirmwareFormComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + this.firmwareData = null; + this.onSaveCallback = null; + this.onCancelCallback = null; + this.isEditMode = false; + } + + setFirmwareData(firmwareData) { + this.firmwareData = firmwareData; + this.isEditMode = !!firmwareData; + } + + setOnSaveCallback(callback) { + this.onSaveCallback = callback; + } + + setOnCancelCallback(callback) { + this.onCancelCallback = callback; + } + + setupEventListeners() { + // Submit button + const submitBtn = this.findElement('button[type="submit"]'); + if (submitBtn) { + this.addEventListener(submitBtn, 'click', this.handleSubmit.bind(this)); + } + + // Cancel button + const cancelBtn = this.findElement('#cancel-btn'); + if (cancelBtn) { + this.addEventListener(cancelBtn, 'click', this.handleCancel.bind(this)); + } + + // File input + const fileInput = this.findElement('#firmware-file'); + if (fileInput) { + this.addEventListener(fileInput, 'change', this.handleFileSelect.bind(this)); + } + + // Labels management + this.setupLabelsManagement(); + } + + setupLabelsManagement() { + // Add label button + const addLabelBtn = this.findElement('#add-label-btn'); + if (addLabelBtn) { + this.addEventListener(addLabelBtn, 'click', this.addLabel.bind(this)); + } + + // Remove label buttons (delegated event handling) + const labelsContainer = this.findElement('#labels-container'); + if (labelsContainer) { + this.addEventListener(labelsContainer, 'click', (e) => { + const removeBtn = e.target.closest('.remove-label-btn'); + if (removeBtn) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + const key = removeBtn.getAttribute('data-label-key'); + if (key) { + this.removeLabel(key); + } + } + }); + } + } + + mount() { + super.mount(); + this.render(); + this.setupEventListeners(); + } + + render() { + const container = this.container; + if (!container) return; + + const labels = this.firmwareData?.labels || {}; + const labelsHTML = Object.entries(labels).map(([key, value]) => + `
+ ${this.escapeHtml(key)} + ${this.escapeHtml(value)} + +
` + ).join(''); + + container.innerHTML = ` +
+
+ + + ${this.isEditMode ? 'Name cannot be changed after creation' : 'Unique identifier for the firmware'} +
+ +
+ + + ${this.isEditMode ? 'Version cannot be changed after creation' : 'Semantic version (e.g., 1.0.0)'} +
+ +
+ +
+ + +
+ ${this.isEditMode ? 'Select a new firmware file to update, or leave empty to update metadata only' : 'Binary firmware file (.bin or .hex)'} +
+ +
+ +
+
+ + : + + +
+
+ ${labelsHTML} +
+
+ Key-value pairs for categorizing firmware (e.g., platform: esp32, app: base) +
+ +
+ + +
+
+ `; + } + + handleFileSelect(event) { + const file = event.target.files[0]; + const fileNameSpan = this.findElement('#file-name'); + + if (file) { + fileNameSpan.textContent = file.name; + } else { + fileNameSpan.textContent = this.isEditMode ? + 'Current file: ' + (this.firmwareData?.name || 'unknown') + '.bin' : + 'Choose firmware file...'; + } + } + + addLabel() { + const keyInput = this.findElement('#label-key'); + const valueInput = this.findElement('#label-value'); + const labelsContainer = this.findElement('#labels-container'); + + const key = keyInput.value.trim(); + const value = valueInput.value.trim(); + + if (!key || !value) { + this.showError('Please enter both key and value for the label'); + return; + } + + // Check if key already exists + const existingLabel = labelsContainer.querySelector(`[data-label-key="${this.escapeHtml(key)}"]`); + if (existingLabel) { + this.showError('A label with this key already exists'); + return; + } + + // Add the label + const labelHTML = ` +
+ ${this.escapeHtml(key)} + ${this.escapeHtml(value)} + +
+ `; + + labelsContainer.insertAdjacentHTML('beforeend', labelHTML); + + // Clear inputs + keyInput.value = ''; + valueInput.value = ''; + } + + removeLabel(key) { + const removeBtn = this.findElement(`.remove-label-btn[data-label-key="${this.escapeHtml(key)}"]`); + if (removeBtn) { + const labelItem = removeBtn.closest('.label-item'); + if (labelItem) { + labelItem.remove(); + } + } + } + + async handleSubmit(event) { + event.preventDefault(); + + try { + const nameInput = this.findElement('#firmware-name'); + const versionInput = this.findElement('#firmware-version'); + const firmwareFile = this.findElement('#firmware-file').files[0]; + + const name = nameInput.value.trim(); + const version = versionInput.value.trim(); + + if (!name || !version) { + this.showError('Name and version are required'); + return; + } + + // Only require file for new uploads, not for edit mode when keeping existing file + if (!this.isEditMode && (!firmwareFile || firmwareFile.size === 0)) { + this.showError('Please select a firmware file'); + return; + } + + // Collect labels + const labels = {}; + const labelItems = this.findAllElements('.label-item'); + labelItems.forEach(item => { + const key = item.querySelector('.label-key').textContent; + const value = item.querySelector('.label-value').textContent; + labels[key] = value; + }); + + // Prepare metadata + const metadata = { + name, + version, + labels + }; + + // Handle upload vs metadata-only update + if (this.isEditMode && (!firmwareFile || firmwareFile.size === 0)) { + // Metadata-only update + await window.apiClient.updateFirmwareMetadata(name, version, metadata); + } else { + // Full upload (new firmware or edit with new file) + await window.apiClient.uploadFirmwareToRegistry(metadata, firmwareFile); + } + + this.showSuccess(this.isEditMode ? 'Firmware updated successfully' : 'Firmware uploaded successfully'); + + if (this.onSaveCallback) { + this.onSaveCallback(); + } + + } catch (error) { + logger.error('Firmware upload failed:', error); + this.showError('Upload failed: ' + error.message); + } + } + + handleCancel() { + if (this.onCancelCallback) { + this.onCancelCallback(); + } + } + + showError(message) { + this.showNotification(message, 'error'); + } + + showSuccess(message) { + this.showNotification(message, 'success'); + } + + showNotification(message, type = 'info') { + // Remove any existing notifications + const existing = this.findElement('.form-notification'); + if (existing) { + existing.remove(); + } + + const notification = document.createElement('div'); + notification.className = `form-notification notification-${type}`; + notification.textContent = message; + + this.container.insertBefore(notification, this.container.firstChild); + + setTimeout(() => { + notification.classList.add('show'); + }, 100); + + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => { + if (notification.parentNode) { + notification.remove(); + } + }, 300); + }, 3000); + } + + escapeHtml(text) { + if (typeof text !== 'string') return text; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +window.FirmwareFormComponent = FirmwareFormComponent; diff --git a/public/scripts/components/FirmwareViewComponent.js b/public/scripts/components/FirmwareViewComponent.js index 82aecca..5142eb8 100644 --- a/public/scripts/components/FirmwareViewComponent.js +++ b/public/scripts/components/FirmwareViewComponent.js @@ -6,11 +6,11 @@ class FirmwareViewComponent extends Component { logger.debug('FirmwareViewComponent: Constructor called'); logger.debug('FirmwareViewComponent: Container:', container); - const firmwareContainer = this.findElement('#firmware-container'); - logger.debug('FirmwareViewComponent: Firmware container found:', !!firmwareContainer); + // Pass the entire firmware view container to the FirmwareComponent + logger.debug('FirmwareViewComponent: Using entire container for FirmwareComponent'); this.firmwareComponent = new FirmwareComponent( - firmwareContainer, + container, viewModel, eventBus ); @@ -26,9 +26,6 @@ class FirmwareViewComponent extends Component { // Mount sub-component this.firmwareComponent.mount(); - // Update available nodes - this.updateAvailableNodes(); - logger.debug('FirmwareViewComponent: Mounted successfully'); } @@ -67,16 +64,4 @@ class FirmwareViewComponent extends Component { return false; } - async updateAvailableNodes() { - try { - logger.debug('FirmwareViewComponent: updateAvailableNodes called'); - const response = await window.apiClient.getClusterMembers(); - const nodes = response.members || []; - logger.debug('FirmwareViewComponent: Got nodes:', nodes); - this.viewModel.updateAvailableNodes(nodes); - logger.debug('FirmwareViewComponent: Available nodes updated in view model'); - } catch (error) { - logger.error('Failed to update available nodes:', error); - } - } } \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index 045abb3..4e12618 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -607,6 +607,228 @@ p { cursor: not-allowed; } +/* Firmware Form Styles */ +.firmware-form { + margin-bottom: 2rem; +} + +.firmware-form .form-group { + margin-bottom: 1rem; +} + +.firmware-form .form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; +} + +.firmware-form .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; +} + +.firmware-form .form-group input:focus { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); +} + +.firmware-form .form-group input::placeholder { + color: var(--text-tertiary); +} + +.firmware-form .form-group input[readonly] { + background: rgba(0, 0, 0, 0.4); + color: var(--text-tertiary); + cursor: not-allowed; + opacity: 0.6; + border-color: var(--border-primary); +} + +.firmware-form .form-help { + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.4; + margin-top: 0.25rem; +} + +.file-input-wrapper { + position: relative; +} + +.file-input-wrapper input[type="file"] { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; +} + +.file-input-label { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--bg-secondary); + border: 1px solid var(--border-secondary); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.file-input-label:hover { + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); +} + +.labels-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.add-label-controls { + 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, +.label-value-input { + flex: 1; + padding: 0.5rem 0.75rem; + background: var(--bg-secondary); + border: 1px solid var(--border-secondary); + border-radius: 6px; + color: var(--text-primary); + font-size: 0.9rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.label-key-input:focus, +.label-value-input:focus { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); +} + +.label-key-input::placeholder, +.label-value-input::placeholder { + color: var(--text-tertiary); +} + +.label-separator { + color: var(--text-secondary); + font-weight: 600; +} + +.labels-container { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.label-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: 8px; +} + +.label-key { + font-weight: 600; + color: var(--text-primary); + min-width: 80px; +} + +.label-value { + flex: 1; + color: var(--text-primary); +} + +.remove-label-btn { + background: transparent; + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 4px; + padding: 0.25rem; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.remove-label-btn:hover { + background: rgba(239, 68, 68, 0.1); + border-color: #ef4444; + color: #ef4444; +} + +.firmware-form .firmware-actions { + display: flex !important; + flex-direction: row !important; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border-primary); +} + +.firmware-actions .config-btn { + background: rgba(255, 255, 255, 0.1); + 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; +} + +.firmware-actions .config-btn:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.firmware-actions .config-btn:active { + transform: translateY(0); +} + +.firmware-actions .config-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + background: rgba(255, 255, 255, 0.05); + color: var(--text-tertiary); + border-color: var(--border-primary); +} + /* Results Section */ .results-section { margin-top: 1.5rem; @@ -1930,6 +2152,703 @@ p { overflow: visible; /* Allow content to expand and scroll */ } +/* Firmware Header */ +.firmware-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-secondary); + gap: 1rem; +} + + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.registry-status { + display: flex; + align-items: center; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; +} + +.status-indicator.connected { + color: #4ade80; +} + +.status-indicator.disconnected { + color: #f87171; +} + +.add-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; +} + +.add-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); +} + +/* Firmware Content */ +.firmware-content { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.firmware-search { + display: flex; + justify-content: flex-start; + flex: 1; + max-width: 400px; +} + +.search-input-wrapper { + position: relative; + width: 100%; + max-width: 400px; +} + +.search-input-wrapper .search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + pointer-events: none; +} + +#firmware-search { + width: 100%; + padding: 0.75rem 0.75rem 0.75rem 2.5rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.875rem; + transition: all 0.2s ease; +} + +#firmware-search:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.firmware-list-container { + min-height: 200px; +} + +/* Firmware Groups */ +.firmware-groups { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.firmware-group { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 10px; + padding: 1rem; + transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease, opacity 0.2s ease; + position: relative; + margin-bottom: 0.5rem; + opacity: 1; + z-index: 1; + -webkit-tap-highlight-color: transparent; +} + +.firmware-group::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-hover); + border-radius: 12px; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.firmware-group:hover::before { + opacity: 1; +} + +.firmware-group:hover { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); + z-index: 2; +} + +.firmware-group-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-primary); +} + +.firmware-group-name { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.firmware-group-count { + background: var(--bg-primary); + color: var(--text-secondary); + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.firmware-versions { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.firmware-version-item { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 0.75rem 1rem; + transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease, opacity 0.2s ease; + position: relative; + margin-bottom: 0.5rem; + opacity: 1; + z-index: 1; + -webkit-tap-highlight-color: transparent; +} + +.firmware-version-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-hover); + border-radius: 10px; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.firmware-version-item.clickable { + cursor: pointer; +} + +.firmware-version-item:hover::before { + opacity: 1; +} + +.firmware-version-item:hover { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); + z-index: 2; +} + +.firmware-version-item.clickable:hover { + transform: translateY(-1px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(59, 130, 246, 0.15); +} + +/* Disable hover effects on touch devices to prevent flicker */ +@media (hover: none) { + .firmware-group:hover::before { + opacity: 0 !important; + } + .firmware-group:hover { + box-shadow: none !important; + z-index: 1 !important; + } + .firmware-version-item:hover::before { + opacity: 0 !important; + } + .firmware-version-item:hover { + box-shadow: none !important; + z-index: 1 !important; + } + .firmware-version-item.clickable:hover { + transform: none !important; + box-shadow: none !important; + } +} + +.firmware-version-main { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.firmware-version-info { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; +} + +.firmware-version-number { + font-weight: 600; + color: var(--text-primary); + font-size: 0.875rem; + min-width: 80px; +} + +.firmware-version-info .firmware-size { + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + min-width: 60px; +} + +.firmware-version-labels { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + min-width: 0; +} + +.firmware-version-actions { + display: flex; + gap: 0.25rem; + margin-left: 1rem; +} + +/* Firmware List */ +.firmware-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.firmware-list-item { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: 8px; + padding: 0.75rem 1rem; + transition: all 0.2s ease; +} + +.firmware-list-item:hover { + border-color: var(--accent-primary); + background: var(--bg-hover); +} + +.firmware-item-main { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.firmware-item-info { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; +} + +.firmware-item-info .firmware-name { + font-weight: 600; + color: var(--text-primary); + font-size: 0.875rem; + min-width: 120px; +} + +.firmware-item-info .firmware-version { + color: var(--text-secondary); + background: var(--bg-primary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + min-width: 60px; +} + +.firmware-item-info .firmware-size { + color: var(--text-secondary); + font-size: 0.75rem; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + min-width: 60px; +} + +.firmware-item-labels { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + min-width: 0; +} + +.firmware-item-actions { + display: flex; + gap: 0.25rem; + margin-left: 1rem; +} + +.firmware-card { + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: 12px; + padding: 1rem; + transition: all 0.2s ease; +} + +.firmware-card:hover { + border-color: var(--accent-primary); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1); + transform: translateY(-2px); +} + +.firmware-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.firmware-info h3 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.firmware-version { + font-size: 0.875rem; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-weight: 500; +} + +.firmware-actions { + display: flex; + gap: 0.25rem; +} + +.action-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; +} + +.action-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-primary); +} + +.action-btn.edit-btn:hover { + background: rgba(59, 130, 246, 0.1); + border-color: #3b82f6; + color: #3b82f6; +} + +.action-btn.download-btn:hover { + background: rgba(34, 197, 94, 0.1); + border-color: #22c55e; + color: #22c55e; +} + +.action-btn.delete-btn:hover { + background: rgba(239, 68, 68, 0.1); + border-color: #ef4444; + color: #ef4444; +} + +.firmware-card-body { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.firmware-details { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.detail-item { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; +} + +.detail-label { + color: var(--text-secondary); + font-weight: 500; +} + +.detail-value { + color: var(--text-primary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8rem; +} + +.firmware-labels { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.labels-title { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +.labels-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.label-chip { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + text-align: center; + color: var(--text-secondary); +} + +.empty-icon { + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.empty-description { + font-size: 0.875rem; + line-height: 1.5; +} + +/* Loading State */ +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} + +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-secondary); + border-top: 3px solid var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading-text { + font-size: 0.875rem; + font-weight: 500; +} + +/* Form Notifications */ +.form-notification { + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 1rem; + opacity: 0; + transform: translateY(-10px); + transition: all 0.3s ease; +} + +.form-notification.show { + opacity: 1; + transform: translateY(0); +} + +.notification-success { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.notification-error { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.notification-info { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +/* Global Notifications */ +.notification { + position: fixed; + top: 1rem; + right: 1rem; + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + z-index: 10000; + opacity: 0; + transform: translateX(100%); + transition: all 0.3s ease; +} + +.notification.show { + opacity: 1; + transform: translateX(0); +} + +.notification-success { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.notification-error { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.notification-info { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +/* Overlay Dialog */ +.overlay-dialog { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.dialog { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 12px; + box-shadow: var(--shadow-primary); + max-width: 400px; + width: 90%; + max-height: 80vh; + overflow: hidden; +} + +.dialog-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-secondary); +} + +.dialog-header h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.dialog-body { + padding: 1.5rem; +} + +.dialog-body p { + margin: 0; + color: var(--text-primary); + line-height: 1.5; +} + +.dialog-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-secondary); + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + .firmware-section::before { content: ''; position: absolute; @@ -6207,12 +7126,15 @@ html { .add-label-form { display: flex; - gap: 0.75rem; align-items: end; - flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; } -.form-group { +.add-label-form .form-group { display: flex; flex-direction: column; gap: 0.25rem; @@ -6220,15 +7142,15 @@ html { min-width: 120px; } -.form-group label { +.add-label-form .form-group label { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); } -.form-group input { - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); +.add-label-form .form-group input { + background: var(--bg-secondary); + border: 1px solid var(--border-secondary); border-radius: 6px; padding: 0.5rem 0.75rem; color: var(--text-primary); @@ -6236,18 +7158,18 @@ html { transition: all 0.2s ease; } -.form-group input:focus { +.add-label-form .form-group input:focus { outline: none; - border-color: var(--accent-primary); - background: var(--bg-secondary); + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); } -.form-group input::placeholder { +.add-label-form .form-group input::placeholder { color: var(--text-tertiary); } -.add-label-btn { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); +.add-label-form .add-label-btn { + background: rgba(255, 255, 255, 0.1); border: 1px solid var(--border-secondary); color: var(--text-secondary); border-radius: 8px; @@ -6260,19 +7182,53 @@ html { gap: 0.5rem; transition: all 0.2s ease; height: fit-content; + margin-bottom: 1rem; +} + +.add-label-form .add-label-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); + color: var(--text-primary); +} + +.add-label-form .add-label-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.add-label-form .add-label-btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + stroke-width: 2; +} + +.add-label-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 8px; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + display: flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; + height: 2.5rem; + align-self: flex-end; } .add-label-btn:hover:not(:disabled) { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); + background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.25); color: var(--text-primary); - transform: translateY(-1px); } .add-label-btn:disabled { opacity: 0.5; cursor: not-allowed; - transform: none; } .add-label-btn svg { diff --git a/test/registry-integration-test.js b/test/registry-integration-test.js new file mode 100755 index 0000000..89745cc --- /dev/null +++ b/test/registry-integration-test.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +/** + * Registry Integration Test + * + * Tests the registry API integration to ensure the firmware registry functionality works correctly + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const REGISTRY_URL = 'http://localhost:8080'; +const TIMEOUT = 10000; // 10 seconds + +function makeRequest(path, method = 'GET', body = null, isFormData = false) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'localhost', + port: 8080, + path: path, + method: method, + headers: {} + }; + + if (body && !isFormData) { + options.headers['Content-Type'] = 'application/json'; + } + + const req = http.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const jsonData = JSON.parse(data); + resolve({ status: res.statusCode, data: jsonData }); + } catch (error) { + resolve({ status: res.statusCode, data: data }); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.setTimeout(TIMEOUT, () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + if (body) { + req.write(body); + } + + req.end(); + }); +} + +async function testRegistryHealth() { + console.log('Testing registry health endpoint...'); + try { + const response = await makeRequest('/health'); + if (response.status === 200 && response.data.status === 'healthy') { + console.log('✅ Registry health check passed'); + return true; + } else { + console.log('❌ Registry health check failed:', response); + return false; + } + } catch (error) { + console.log('❌ Registry health check failed:', error.message); + return false; + } +} + +async function testListFirmware() { + console.log('Testing list firmware endpoint...'); + try { + const response = await makeRequest('/firmware'); + if (response.status === 200 && Array.isArray(response.data)) { + console.log('✅ List firmware endpoint works, found', response.data.length, 'firmware entries'); + return true; + } else { + console.log('❌ List firmware endpoint failed:', response); + return false; + } + } catch (error) { + console.log('❌ List firmware endpoint failed:', error.message); + return false; + } +} + +async function testUploadFirmware() { + console.log('Testing upload firmware endpoint...'); + + // Create a small test firmware file + const testFirmwareContent = Buffer.from('test firmware content'); + const metadata = { + name: 'test-firmware', + version: '1.0.0', + labels: { + platform: 'esp32', + app: 'test' + } + }; + + try { + // Create multipart form data + const boundary = '----formdata-test-boundary'; + const formData = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="metadata"', + 'Content-Type: application/json', + '', + JSON.stringify(metadata), + `--${boundary}`, + 'Content-Disposition: form-data; name="firmware"; filename="test.bin"', + 'Content-Type: application/octet-stream', + '', + testFirmwareContent.toString(), + `--${boundary}--` + ].join('\r\n'); + + const response = await makeRequest('/firmware', 'POST', formData, true); + + if (response.status === 201 && response.data.success) { + console.log('✅ Upload firmware endpoint works'); + return true; + } else { + console.log('❌ Upload firmware endpoint failed:', response); + return false; + } + } catch (error) { + console.log('❌ Upload firmware endpoint failed:', error.message); + return false; + } +} + +async function testDownloadFirmware() { + console.log('Testing download firmware endpoint...'); + try { + const response = await makeRequest('/firmware/test-firmware/1.0.0'); + if (response.status === 200) { + console.log('✅ Download firmware endpoint works'); + return true; + } else { + console.log('❌ Download firmware endpoint failed:', response); + return false; + } + } catch (error) { + console.log('❌ Download firmware endpoint failed:', error.message); + return false; + } +} + +async function runTests() { + console.log('Starting Registry Integration Tests...\n'); + + const tests = [ + { name: 'Health Check', fn: testRegistryHealth }, + { name: 'List Firmware', fn: testListFirmware }, + { name: 'Upload Firmware', fn: testUploadFirmware }, + { name: 'Download Firmware', fn: testDownloadFirmware } + ]; + + let passed = 0; + let total = tests.length; + + for (const test of tests) { + console.log(`\n--- ${test.name} ---`); + try { + const result = await test.fn(); + if (result) { + passed++; + } + } catch (error) { + console.log(`❌ ${test.name} failed with error:`, error.message); + } + } + + console.log(`\n--- Test Results ---`); + console.log(`Passed: ${passed}/${total}`); + + if (passed === total) { + console.log('🎉 All tests passed! Registry integration is working correctly.'); + process.exit(0); + } else { + console.log('⚠️ Some tests failed. Please check the registry server.'); + process.exit(1); + } +} + +// Run tests if this script is executed directly +if (require.main === module) { + runTests().catch(error => { + console.error('Test runner failed:', error); + process.exit(1); + }); +} + +module.exports = { + testRegistryHealth, + testListFirmware, + testUploadFirmware, + testDownloadFirmware, + runTests +}; -- 2.49.1 From cdb42c459a71246a172783f2609eae9a56c6969f Mon Sep 17 00:00:00 2001 From: 0x1d Date: Tue, 21 Oct 2025 21:01:56 +0200 Subject: [PATCH 2/2] 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 | 429 ++++++++++++++++-- public/styles/theme.css | 2 +- 7 files changed, 1059 insertions(+), 175 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..e2d33d2 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 { @@ -4005,7 +4026,6 @@ select.param-input:focus { gap: 0.25rem; padding: 0.25rem; border-radius: 12px; - background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); backdrop-filter: var(--backdrop-blur); border-bottom: none; @@ -4092,7 +4112,6 @@ select.param-input:focus { border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 12px; padding: 0.75rem; - background: var(--bg-tertiary); backdrop-filter: var(--backdrop-blur); } @@ -4123,7 +4142,6 @@ select.param-input:focus { } .tabs-header { - background: rgba(255, 255, 255, 0.10); border: 1px solid rgba(255, 255, 255, 0.14); } @@ -4148,7 +4166,6 @@ select.param-input:focus { .tab-content { border: 1px solid var(--border-primary); - background: rgba(255, 255, 255, 0.08); } /* Active tab: no background or border (keep underline) */ @@ -4461,7 +4478,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 +4950,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 +5016,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 +5048,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 +6481,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 +6741,9 @@ html { .node-labels { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; margin-bottom: 0.75rem; } @@ -6733,7 +6764,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 +7504,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; -- 2.49.1