Merge pull request 'feature/registry' (#22) from feature/registry into main

Reviewed-on: #22
This commit is contained in:
2025-10-22 21:51:19 +02:00
13 changed files with 3212 additions and 1069 deletions

View File

@@ -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.

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

View File

@@ -147,77 +147,51 @@
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<div id="firmware-container">
<div class="firmware-overview">
<div class="firmware-actions">
<div class="action-group">
<h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
<path d="M12 8v8"/>
</svg>
Firmware Update
</h3>
<div class="firmware-upload-compact">
<div class="compact-upload-row">
<div class="file-upload-area">
<div class="target-options">
<label class="target-option">
<input type="radio" name="target-type" value="all" checked>
<span class="radio-custom"></span>
<span class="target-label">All Nodes</span>
</label>
<label class="target-option specific-node-option">
<input type="radio" name="target-type" value="specific">
<span class="radio-custom"></span>
<span class="target-label">Specific Node</span>
<select id="specific-node-select" class="node-select">
<option value="">Select a node...</option>
</select>
</label>
<label class="target-option by-label-option">
<input type="radio" name="target-type" value="labels">
<span class="radio-custom"></span>
<span class="target-label">By Label</span>
<select id="label-select" class="label-select"
style="min-width: 220px; display: inline-block; vertical-align: middle;">
<option value="">Select a label...</option>
</select>
<div id="selected-labels-container" class="selected-labels"></div>
</label>
</div>
<div class="file-input-wrapper">
<input type="file" id="global-firmware-file" accept=".bin,.hex"
style="display: none;">
<button class="upload-btn-compact"
onclick="document.getElementById('global-firmware-file').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:6px; vertical-align: -2px;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
</svg>
Choose File
</button>
<span class="file-info" id="file-info">No file selected</span>
</div>
</div>
<button class="deploy-btn" id="deploy-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
<path d="M12 16V4"/>
<path d="M8 8l4-4 4 4"/>
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>
</svg>
Deploy
</button>
</div>
</div>
</div>
<div class="firmware-header">
<div class="firmware-search">
<div class="search-input-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" class="search-icon">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" id="firmware-search" placeholder="Search firmware by name, version, or labels (e.g., '1.0.0 base')...">
</div>
</div>
<div class="firmware-nodes-list" id="firmware-nodes-list">
<!-- Nodes will be populated here -->
<div class="header-actions">
<div id="registry-status" class="registry-status">
<span class="status-indicator disconnected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
Registry Disconnected
</span>
</div>
<button class="refresh-btn" id="refresh-firmware-btn" title="Refresh firmware list">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
<button class="add-btn" id="add-firmware-btn" title="Add new firmware">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Firmware
</button>
</div>
</div>
<div class="firmware-content">
<div id="firmware-list-container" class="firmware-list-container">
<div class="loading-state">
<div class="loading-spinner"></div>
<div class="loading-text">Loading firmware...</div>
</div>
</div>
</div>
</div>
@@ -272,7 +246,9 @@
<script src="./scripts/components/ClusterMembersComponent.js"></script>
<script src="./scripts/components/OverlayDialogComponent.js"></script>
<script src="./scripts/components/FirmwareComponent.js"></script>
<script src="./scripts/components/FirmwareFormComponent.js"></script>
<script src="./scripts/components/FirmwareUploadComponent.js"></script>
<script src="./scripts/components/RolloutComponent.js"></script>
<script src="./scripts/components/WiFiConfigComponent.js"></script>
<!-- Container/view components after their deps -->
<script src="./scripts/components/FirmwareViewComponent.js"></script>

View File

@@ -136,6 +136,69 @@ class ApiClient {
}
});
}
// Registry API methods - now proxied through gateway
async getRegistryHealth() {
return this.request('/api/registry/health', { method: 'GET' });
}
async uploadFirmwareToRegistry(metadata, firmwareFile) {
const formData = new FormData();
formData.append('metadata', JSON.stringify(metadata));
formData.append('firmware', firmwareFile);
return this.request('/api/registry/firmware', {
method: 'POST',
body: formData,
isForm: true,
headers: {}
});
}
async updateFirmwareMetadata(name, version, metadata) {
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
method: 'PUT',
body: metadata
});
}
async listFirmwareFromRegistry(name = null, version = null) {
const query = {};
if (name) query.name = name;
if (version) query.version = version;
const queryString = Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : '';
return this.request(`/api/registry/firmware${queryString}`, { method: 'GET' });
}
async downloadFirmwareFromRegistry(name, version) {
const response = await fetch(`${this.baseURL}/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Registry download failed: ${errorText}`);
}
return response.blob();
}
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
});
}
}
// Global API client instance
@@ -228,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);
}

View File

@@ -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) => {

File diff suppressed because it is too large Load Diff

View File

@@ -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]) =>
`<div class="label-item">
<span class="label-key">${this.escapeHtml(key)}</span>
<span class="label-value">${this.escapeHtml(value)}</span>
<button type="button" class="remove-label-btn" data-label-key="${this.escapeHtml(key)}" title="Remove label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>`
).join('');
container.innerHTML = `
<div class="firmware-form">
<div class="form-group">
<label for="firmware-name">Name *</label>
<input
type="text"
id="firmware-name"
name="name"
value="${this.firmwareData?.name || ''}"
placeholder="e.g., base, neopattern, relay"
${this.isEditMode ? 'readonly' : ''}
>
<small class="form-help">${this.isEditMode ? 'Name cannot be changed after creation' : 'Unique identifier for the firmware'}</small>
</div>
<div class="form-group">
<label for="firmware-version">Version *</label>
<input
type="text"
id="firmware-version"
name="version"
value="${this.firmwareData?.version || ''}"
placeholder="e.g., 1.0.0, 2.1.3"
${this.isEditMode ? 'readonly' : ''}
>
<small class="form-help">${this.isEditMode ? 'Version cannot be changed after creation' : 'Semantic version (e.g., 1.0.0)'}</small>
</div>
<div class="form-group">
<label for="firmware-file">Firmware File *</label>
<div class="file-input-wrapper">
<input
type="file"
id="firmware-file"
name="firmware"
accept=".bin,.hex"
>
<label for="firmware-file" class="file-input-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
</svg>
<span id="file-name">${this.isEditMode ? 'Current file: ' + (this.firmwareData?.name || 'unknown') + '.bin' : 'Choose firmware file...'}</span>
</label>
</div>
<small class="form-help">${this.isEditMode ? 'Select a new firmware file to update, or leave empty to update metadata only' : 'Binary firmware file (.bin or .hex)'}</small>
</div>
<div class="form-group">
<label>Labels</label>
<div class="labels-section">
<div class="add-label-controls">
<input type="text" id="label-key" placeholder="Key" class="label-key-input">
<span class="label-separator">:</span>
<input type="text" id="label-value" placeholder="Value" class="label-value-input">
<button type="button" id="add-label-btn" class="add-label-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Label
</button>
</div>
<div id="labels-container" class="labels-container">
${labelsHTML}
</div>
</div>
<small class="form-help">Key-value pairs for categorizing firmware (e.g., platform: esp32, app: base)</small>
</div>
<div class="firmware-actions">
<button type="button" id="cancel-btn" class="config-btn">Cancel</button>
<button type="submit" class="config-btn">
${this.isEditMode ? 'Update Firmware' : 'Upload Firmware'}
</button>
</div>
</div>
`;
}
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 = `
<div class="label-item">
<span class="label-key">${this.escapeHtml(key)}</span>
<span class="label-value">${this.escapeHtml(value)}</span>
<button type="button" class="remove-label-btn" data-label-key="${this.escapeHtml(key)}" title="Remove label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
`;
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;

View File

@@ -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);
}
}
}

View File

@@ -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 = `
<div class="overlay-dialog-content">
<div class="overlay-dialog-header">
<h3 class="overlay-dialog-title">${this.title}</h3>
<button class="overlay-dialog-close" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<h3 class="overlay-dialog-title">${this.escapeHtml(this.title)}</h3>
${this.showCloseButton ? `
<button class="overlay-dialog-close" type="button" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
` : ''}
</div>
<div class="overlay-dialog-body">
<div class="overlay-dialog-message">${this.message}</div>
<p class="overlay-dialog-message">${this.message}</p>
</div>
<div class="overlay-dialog-footer">
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
${this.cancelText}
</button>
<button class="overlay-dialog-btn overlay-dialog-btn-confirm" type="button">
${this.confirmText}
${this.cancelText ? `
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
${this.escapeHtml(this.cancelText)}
</button>
` : ''}
<button class="overlay-dialog-btn ${this.confirmClass}" type="button">
${this.escapeHtml(this.confirmText)}
</button>
</div>
</div>
@@ -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
});
};

View File

@@ -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 = '<div class="error">No rollout data available</div>';
return;
}
const { name, version, labels } = this.rolloutData;
// Render labels as chips
const labelsHTML = Object.entries(labels).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
// Render matching nodes
const nodesHTML = this.matchingNodes.map(node => {
const nodeLabelsHTML = Object.entries(node.labels || {}).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
return `
<div class="rollout-node-item" data-node-ip="${node.ip}">
<div class="rollout-node-info">
<div class="rollout-node-ip">${this.escapeHtml(node.ip)}</div>
<div class="rollout-node-version">Version: ${this.escapeHtml(node.version)}</div>
</div>
<div class="rollout-node-status">
<div class="rollout-node-labels">
${nodeLabelsHTML}
</div>
<div class="status-indicator ready" style="display: none;">Ready</div>
</div>
</div>
`;
}).join('');
this.container.innerHTML = `
<div class="rollout-panel">
<div class="rollout-header">
<p>Deploy firmware to matching cluster nodes</p>
</div>
<div class="rollout-firmware-info">
<div class="rollout-firmware-name">${this.escapeHtml(name)}</div>
<div class="rollout-firmware-version">Version: ${this.escapeHtml(version)}</div>
<div class="rollout-firmware-labels">
${labelsHTML}
</div>
</div>
<div class="rollout-matching-nodes">
<h4>Matching Nodes (${this.matchingNodes.length})</h4>
<div class="rollout-nodes-list">
${nodesHTML}
</div>
</div>
<div class="rollout-warning">
<div class="warning-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="warning-text">
<strong>Warning:</strong> This will update firmware on ${this.matchingNodes.length} node${this.matchingNodes.length !== 1 ? 's' : ''}.
The rollout process cannot be cancelled once started.
</div>
</div>
<div class="rollout-actions">
<button id="rollout-cancel-btn" class="refresh-btn">Cancel</button>
<button id="rollout-confirm-btn" class="deploy-btn" ${this.matchingNodes.length === 0 ? 'disabled' : ''}>
Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}
</button>
</div>
</div>
`;
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 <strong>${this.escapeHtml(name)}</strong> version <strong>${this.escapeHtml(version)}</strong> to <strong>${nodeCount} node${nodePlural}</strong>?<br><br>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;

File diff suppressed because it is too large Load Diff

View File

@@ -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;

212
test/registry-integration-test.js Executable file
View File

@@ -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
};