Files
spore-ui/public/scripts/components/FirmwareComponent.js
2025-10-21 20:17:18 +02:00

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;