Merge pull request 'feature/registry' (#22) from feature/registry into main
Reviewed-on: #22
This commit is contained in:
169
FIRMWARE_REGISTRY_INTEGRATION.md
Normal file
169
FIRMWARE_REGISTRY_INTEGRATION.md
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 728 B |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
350
public/scripts/components/FirmwareFormComponent.js
Normal file
350
public/scripts/components/FirmwareFormComponent.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
271
public/scripts/components/RolloutComponent.js
Normal file
271
public/scripts/components/RolloutComponent.js
Normal 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
@@ -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
212
test/registry-integration-test.js
Executable 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
|
||||
};
|
||||
Reference in New Issue
Block a user