feat: firmware registry view
This commit is contained in:
@@ -136,6 +136,98 @@ class ApiClient {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Registry API methods
|
||||
async getRegistryBaseUrl() {
|
||||
// Auto-detect registry server URL based on current location
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// If accessing from localhost, use localhost:8080
|
||||
// If accessing from another device, use the same hostname but port 8080
|
||||
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
|
||||
return 'http://localhost:8080';
|
||||
} else {
|
||||
return `http://${currentHost}:8080`;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFirmwareToRegistry(metadata, firmwareFile) {
|
||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
||||
const formData = new FormData();
|
||||
formData.append('metadata', JSON.stringify(metadata));
|
||||
formData.append('firmware', firmwareFile);
|
||||
|
||||
const response = await fetch(`${registryBaseUrl}/firmware`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Registry upload failed: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async updateFirmwareMetadata(name, version, metadata) {
|
||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
||||
|
||||
const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Registry metadata update failed: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async listFirmwareFromRegistry(name = null, version = null) {
|
||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
||||
const query = {};
|
||||
if (name) query.name = name;
|
||||
if (version) query.version = version;
|
||||
|
||||
const response = await fetch(`${registryBaseUrl}/firmware${Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : ''}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Registry list failed: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async downloadFirmwareFromRegistry(name, version) {
|
||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
||||
const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Registry download failed: ${errorText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async getRegistryHealth() {
|
||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
||||
const response = await fetch(`${registryBaseUrl}/health`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Registry health check failed: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user