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 0000000..1658e25 Binary files /dev/null and b/public/favicon.ico differ 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 +};