528 lines
21 KiB
JavaScript
528 lines
21 KiB
JavaScript
// Registry Firmware Component - CRUD interface for firmware registry
|
|
class FirmwareComponent extends Component {
|
|
constructor(container, viewModel, eventBus) {
|
|
super(container, viewModel, eventBus);
|
|
|
|
logger.debug('FirmwareComponent: Constructor called');
|
|
logger.debug('FirmwareComponent: Container:', container);
|
|
|
|
// Initialize drawer component
|
|
this.drawer = new DrawerComponent();
|
|
|
|
// Registry connection status
|
|
this.registryConnected = false;
|
|
this.registryError = null;
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Setup refresh button
|
|
const refreshBtn = this.findElement('#refresh-firmware-btn');
|
|
if (refreshBtn) {
|
|
this.addEventListener(refreshBtn, 'click', this.refreshFirmwareList.bind(this));
|
|
}
|
|
|
|
// Setup add firmware button
|
|
const addBtn = this.findElement('#add-firmware-btn');
|
|
if (addBtn) {
|
|
this.addEventListener(addBtn, 'click', this.showAddFirmwareForm.bind(this));
|
|
}
|
|
|
|
// Setup search input
|
|
const searchInput = this.findElement('#firmware-search');
|
|
if (searchInput) {
|
|
this.addEventListener(searchInput, 'input', this.handleSearch.bind(this));
|
|
}
|
|
}
|
|
|
|
setupViewModelListeners() {
|
|
this.subscribeToProperty('firmwareList', this.renderFirmwareList.bind(this));
|
|
this.subscribeToProperty('isLoading', this.updateLoadingState.bind(this));
|
|
this.subscribeToProperty('searchQuery', this.updateSearchResults.bind(this));
|
|
this.subscribeToProperty('registryConnected', this.updateRegistryStatus.bind(this));
|
|
}
|
|
|
|
mount() {
|
|
super.mount();
|
|
|
|
logger.debug('FirmwareComponent: Mounting...');
|
|
|
|
// Check registry connection and load firmware list
|
|
this.checkRegistryConnection();
|
|
this.loadFirmwareList();
|
|
|
|
logger.debug('FirmwareComponent: Mounted successfully');
|
|
}
|
|
|
|
async checkRegistryConnection() {
|
|
try {
|
|
await window.apiClient.getRegistryHealth();
|
|
this.registryConnected = true;
|
|
this.registryError = null;
|
|
this.viewModel.set('registryConnected', true);
|
|
} catch (error) {
|
|
logger.error('Registry connection failed:', error);
|
|
this.registryConnected = false;
|
|
this.registryError = error.message;
|
|
this.viewModel.set('registryConnected', false);
|
|
}
|
|
}
|
|
|
|
async loadFirmwareList() {
|
|
try {
|
|
this.viewModel.set('isLoading', true);
|
|
const firmwareList = await window.apiClient.listFirmwareFromRegistry();
|
|
this.viewModel.set('firmwareList', firmwareList);
|
|
} catch (error) {
|
|
logger.error('Failed to load firmware list:', error);
|
|
this.viewModel.set('firmwareList', []);
|
|
this.showError('Failed to load firmware list: ' + error.message);
|
|
} finally {
|
|
this.viewModel.set('isLoading', false);
|
|
}
|
|
}
|
|
|
|
async refreshFirmwareList() {
|
|
await this.checkRegistryConnection();
|
|
await this.loadFirmwareList();
|
|
}
|
|
|
|
renderFirmwareList() {
|
|
const container = this.findElement('#firmware-list-container');
|
|
if (!container) return;
|
|
|
|
const groupedFirmware = this.viewModel.get('firmwareList') || [];
|
|
const searchQuery = this.viewModel.get('searchQuery') || '';
|
|
|
|
// Filter grouped firmware based on search query
|
|
const filteredGroups = groupedFirmware.map(group => {
|
|
if (!searchQuery) return group;
|
|
|
|
// Split search query into individual terms
|
|
const searchTerms = searchQuery.toLowerCase().split(/\s+/).filter(term => term.length > 0);
|
|
|
|
// Filter firmware versions within the group
|
|
const filteredFirmware = group.firmware.filter(firmware => {
|
|
// All search terms must match somewhere in the firmware data
|
|
return searchTerms.every(term => {
|
|
// Check group name
|
|
if (group.name.toLowerCase().includes(term)) {
|
|
return true;
|
|
}
|
|
|
|
// Check version
|
|
if (firmware.version.toLowerCase().includes(term)) {
|
|
return true;
|
|
}
|
|
|
|
// Check labels
|
|
if (Object.values(firmware.labels || {}).some(label =>
|
|
label.toLowerCase().includes(term)
|
|
)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
});
|
|
|
|
// Return group with filtered firmware, or null if no firmware matches
|
|
return filteredFirmware.length > 0 ? {
|
|
...group,
|
|
firmware: filteredFirmware
|
|
} : null;
|
|
}).filter(group => group !== null);
|
|
|
|
if (filteredGroups.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="48" height="48">
|
|
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
|
|
<path d="M12 8v8"/>
|
|
</svg>
|
|
</div>
|
|
<div class="empty-title">${searchQuery ? 'No firmware found' : 'No firmware available'}</div>
|
|
<div class="empty-description">
|
|
${searchQuery ? 'Try adjusting your search terms' : 'Upload your first firmware to get started'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const firmwareHTML = filteredGroups.map(group => this.renderFirmwareGroup(group)).join('');
|
|
|
|
container.innerHTML = `
|
|
<div class="firmware-groups">
|
|
${firmwareHTML}
|
|
</div>
|
|
`;
|
|
|
|
// Setup event listeners for firmware items
|
|
this.setupFirmwareItemListeners();
|
|
}
|
|
|
|
renderFirmwareGroup(group) {
|
|
const versionsHTML = group.firmware.map(firmware => this.renderFirmwareVersion(firmware)).join('');
|
|
|
|
return `
|
|
<div class="firmware-group">
|
|
<div class="firmware-group-header">
|
|
<h3 class="firmware-group-name">${this.escapeHtml(group.name)}</h3>
|
|
<span class="firmware-group-count">${group.firmware.length} version${group.firmware.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
<div class="firmware-versions">
|
|
${versionsHTML}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderFirmwareVersion(firmware) {
|
|
const labels = firmware.labels || {};
|
|
const labelsHTML = Object.entries(labels).map(([key, value]) =>
|
|
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
|
|
).join('');
|
|
|
|
const sizeKB = Math.round(firmware.size / 1024);
|
|
|
|
return `
|
|
<div class="firmware-version-item clickable" data-name="${firmware.name}" data-version="${firmware.version}" title="Click to edit firmware">
|
|
<div class="firmware-version-main">
|
|
<div class="firmware-version-info">
|
|
<div class="firmware-version-number">v${this.escapeHtml(firmware.version)}</div>
|
|
<div class="firmware-size">${sizeKB} KB</div>
|
|
</div>
|
|
<div class="firmware-version-labels">
|
|
${labelsHTML}
|
|
</div>
|
|
</div>
|
|
<div class="firmware-version-actions">
|
|
<button class="action-btn download-btn" title="Download firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<polyline points="7,10 12,15 17,10"/>
|
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
</button>
|
|
<button class="action-btn delete-btn" title="Delete firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<polyline points="3,6 5,6 21,6"/>
|
|
<path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"/>
|
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderFirmwareItem(firmware) {
|
|
const labels = firmware.labels || {};
|
|
const labelsHTML = Object.entries(labels).map(([key, value]) =>
|
|
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
|
|
).join('');
|
|
|
|
const sizeKB = Math.round(firmware.size / 1024);
|
|
|
|
return `
|
|
<div class="firmware-list-item" data-name="${firmware.name}" data-version="${firmware.version}">
|
|
<div class="firmware-item-main">
|
|
<div class="firmware-item-info">
|
|
<div class="firmware-name">${this.escapeHtml(firmware.name)}</div>
|
|
<div class="firmware-version">v${this.escapeHtml(firmware.version)}</div>
|
|
<div class="firmware-size">${sizeKB} KB</div>
|
|
</div>
|
|
<div class="firmware-item-labels">
|
|
${labelsHTML}
|
|
</div>
|
|
</div>
|
|
<div class="firmware-item-actions">
|
|
<button class="action-btn edit-btn" title="Edit firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
</svg>
|
|
</button>
|
|
<button class="action-btn download-btn" title="Download firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<polyline points="7,10 12,15 17,10"/>
|
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
</button>
|
|
<button class="action-btn delete-btn" title="Delete firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<polyline points="3,6 5,6 21,6"/>
|
|
<path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"/>
|
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
setupFirmwareItemListeners() {
|
|
// Version item clicks (for editing)
|
|
const versionItems = this.findAllElements('.firmware-version-item.clickable');
|
|
versionItems.forEach(item => {
|
|
this.addEventListener(item, 'click', (e) => {
|
|
// Don't trigger if clicking on action buttons
|
|
if (e.target.closest('.firmware-version-actions')) {
|
|
return;
|
|
}
|
|
|
|
const name = item.getAttribute('data-name');
|
|
const version = item.getAttribute('data-version');
|
|
this.showEditFirmwareForm(name, version);
|
|
});
|
|
});
|
|
|
|
// Download buttons
|
|
const downloadBtns = this.findAllElements('.download-btn');
|
|
downloadBtns.forEach(btn => {
|
|
this.addEventListener(btn, 'click', (e) => {
|
|
e.stopPropagation();
|
|
const name = btn.getAttribute('data-name');
|
|
const version = btn.getAttribute('data-version');
|
|
this.downloadFirmware(name, version);
|
|
});
|
|
});
|
|
|
|
// Delete buttons
|
|
const deleteBtns = this.findAllElements('.delete-btn');
|
|
deleteBtns.forEach(btn => {
|
|
this.addEventListener(btn, 'click', (e) => {
|
|
e.stopPropagation();
|
|
const name = btn.getAttribute('data-name');
|
|
const version = btn.getAttribute('data-version');
|
|
this.showDeleteConfirmation(name, version);
|
|
});
|
|
});
|
|
}
|
|
|
|
showAddFirmwareForm() {
|
|
this.openFirmwareForm('Add Firmware', null, null);
|
|
}
|
|
|
|
showEditFirmwareForm(name, version) {
|
|
const groupedFirmware = this.viewModel.get('firmwareList') || [];
|
|
|
|
// Find the firmware in the grouped data
|
|
let firmware = null;
|
|
for (const group of groupedFirmware) {
|
|
if (group.name === name) {
|
|
firmware = group.firmware.find(f => f.version === version);
|
|
if (firmware) break;
|
|
}
|
|
}
|
|
|
|
if (firmware) {
|
|
this.openFirmwareForm('Edit Firmware', firmware, null);
|
|
}
|
|
}
|
|
|
|
openFirmwareForm(title, firmwareData, onCloseCallback) {
|
|
this.drawer.openDrawer(title, (contentContainer, setActiveComponent) => {
|
|
const formComponent = new FirmwareFormComponent(contentContainer, this.viewModel, this.eventBus);
|
|
setActiveComponent(formComponent);
|
|
|
|
formComponent.setFirmwareData(firmwareData);
|
|
formComponent.setOnSaveCallback(() => {
|
|
this.loadFirmwareList();
|
|
this.drawer.closeDrawer();
|
|
});
|
|
formComponent.setOnCancelCallback(() => {
|
|
this.drawer.closeDrawer();
|
|
});
|
|
|
|
formComponent.mount();
|
|
}, null, onCloseCallback, true); // Hide terminal button
|
|
}
|
|
|
|
async downloadFirmware(name, version) {
|
|
try {
|
|
const blob = await window.apiClient.downloadFirmwareFromRegistry(name, version);
|
|
|
|
// Create download link
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${name}-${version}.bin`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
this.showSuccess(`Firmware ${name} v${version} downloaded successfully`);
|
|
} catch (error) {
|
|
logger.error('Download failed:', error);
|
|
this.showError('Download failed: ' + error.message);
|
|
}
|
|
}
|
|
|
|
showDeleteConfirmation(name, version) {
|
|
this.showConfirmationDialog({
|
|
title: 'Delete Firmware',
|
|
message: `Are you sure you want to delete firmware "${name}" version "${version}"?<br><br>This action cannot be undone.`,
|
|
confirmText: 'Delete',
|
|
cancelText: 'Cancel',
|
|
onConfirm: () => this.deleteFirmware(name, version),
|
|
onCancel: () => {}
|
|
});
|
|
}
|
|
|
|
async deleteFirmware(name, version) {
|
|
try {
|
|
// Note: The registry API doesn't have a delete endpoint in the OpenAPI spec
|
|
// This would need to be implemented in the registry service
|
|
this.showError('Delete functionality not yet implemented in registry API');
|
|
} catch (error) {
|
|
logger.error('Delete failed:', error);
|
|
this.showError('Delete failed: ' + error.message);
|
|
}
|
|
}
|
|
|
|
handleSearch(event) {
|
|
const query = event.target.value;
|
|
this.viewModel.set('searchQuery', query);
|
|
}
|
|
|
|
updateSearchResults() {
|
|
// This method is called when searchQuery property changes
|
|
// The actual filtering is handled in renderFirmwareList
|
|
this.renderFirmwareList();
|
|
}
|
|
|
|
updateLoadingState() {
|
|
const isLoading = this.viewModel.get('isLoading');
|
|
const container = this.findElement('#firmware-list-container');
|
|
|
|
if (isLoading && container) {
|
|
container.innerHTML = `
|
|
<div class="loading-state">
|
|
<div class="loading-spinner"></div>
|
|
<div class="loading-text">Loading firmware...</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
updateRegistryStatus() {
|
|
const isConnected = this.viewModel.get('registryConnected');
|
|
const statusElement = this.findElement('#registry-status');
|
|
|
|
if (statusElement) {
|
|
if (isConnected) {
|
|
statusElement.innerHTML = `
|
|
<span class="status-indicator connected">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
<polyline points="22,4 12,14.01 9,11.01"/>
|
|
</svg>
|
|
Registry Connected
|
|
</span>
|
|
`;
|
|
} else {
|
|
statusElement.innerHTML = `
|
|
<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>
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
showConfirmationDialog(options) {
|
|
// Create a simple confirmation dialog
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'overlay-dialog';
|
|
overlay.innerHTML = `
|
|
<div class="dialog">
|
|
<div class="dialog-header">
|
|
<h3>${options.title}</h3>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<p>${options.message}</p>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
${options.cancelText ? `<button class="btn btn-secondary" id="cancel-btn">${options.cancelText}</button>` : ''}
|
|
<button class="btn btn-primary" id="confirm-btn">${options.confirmText}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
const confirmBtn = overlay.querySelector('#confirm-btn');
|
|
const cancelBtn = overlay.querySelector('#cancel-btn');
|
|
|
|
confirmBtn.addEventListener('click', () => {
|
|
document.body.removeChild(overlay);
|
|
if (options.onConfirm) options.onConfirm();
|
|
});
|
|
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener('click', () => {
|
|
document.body.removeChild(overlay);
|
|
if (options.onCancel) options.onCancel();
|
|
});
|
|
}
|
|
|
|
// Close on escape key
|
|
const handleEscape = (e) => {
|
|
if (e.key === 'Escape') {
|
|
document.body.removeChild(overlay);
|
|
document.removeEventListener('keydown', handleEscape);
|
|
if (options.onCancel) options.onCancel();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleEscape);
|
|
}
|
|
|
|
showSuccess(message) {
|
|
this.showNotification(message, 'success');
|
|
}
|
|
|
|
showError(message) {
|
|
this.showNotification(message, 'error');
|
|
}
|
|
|
|
showNotification(message, type = 'info') {
|
|
const notification = document.createElement('div');
|
|
notification.className = `notification notification-${type}`;
|
|
notification.textContent = message;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
setTimeout(() => {
|
|
notification.classList.add('show');
|
|
}, 100);
|
|
|
|
setTimeout(() => {
|
|
notification.classList.remove('show');
|
|
setTimeout(() => {
|
|
if (notification.parentNode) {
|
|
document.body.removeChild(notification);
|
|
}
|
|
}, 300);
|
|
}, 3000);
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
if (typeof text !== 'string') return text;
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
}
|
|
|
|
window.FirmwareComponent = FirmwareComponent;
|