Compare commits

..

1 Commits

Author SHA1 Message Date
65493b2c17 feat: rollout 2025-10-22 21:23:24 +02:00
5 changed files with 215 additions and 101 deletions

View File

@@ -182,6 +182,12 @@ class ApiClient {
return response.blob(); return response.blob();
} }
async deleteFirmwareFromRegistry(name, version) {
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
method: 'DELETE'
});
}
// Rollout API methods // Rollout API methods
async getClusterNodeVersions() { async getClusterNodeVersions() {
return this.request('/api/cluster/node/versions', { method: 'GET' }); return this.request('/api/cluster/node/versions', { method: 'GET' });

View File

@@ -53,6 +53,11 @@ class FirmwareComponent extends Component {
logger.debug('FirmwareComponent: Mounted successfully'); logger.debug('FirmwareComponent: Mounted successfully');
} }
unmount() {
this.cleanupDynamicListeners();
super.unmount();
}
async checkRegistryConnection() { async checkRegistryConnection() {
try { try {
await window.apiClient.getRegistryHealth(); await window.apiClient.getRegistryHealth();
@@ -278,19 +283,25 @@ class FirmwareComponent extends Component {
} }
setupFirmwareItemListeners() { setupFirmwareItemListeners() {
// First, clean up existing listeners for dynamically created content
this.cleanupDynamicListeners();
this.dynamicUnsubscribers = [];
// Firmware group header clicks (for expand/collapse) // Firmware group header clicks (for expand/collapse)
const groupHeaders = this.findAllElements('.firmware-group-header'); const groupHeaders = this.findAllElements('.firmware-group-header');
groupHeaders.forEach(header => { groupHeaders.forEach(header => {
this.addEventListener(header, 'click', (e) => { const handler = (e) => {
const group = header.closest('.firmware-group'); const group = header.closest('.firmware-group');
group.classList.toggle('expanded'); group.classList.toggle('expanded');
}); };
header.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => header.removeEventListener('click', handler));
}); });
// Version item clicks (for editing) // Version item clicks (for editing)
const versionItems = this.findAllElements('.firmware-version-item.clickable'); const versionItems = this.findAllElements('.firmware-version-item.clickable');
versionItems.forEach(item => { versionItems.forEach(item => {
this.addEventListener(item, 'click', (e) => { const handler = (e) => {
// Don't trigger if clicking on action buttons // Don't trigger if clicking on action buttons
if (e.target.closest('.firmware-version-actions')) { if (e.target.closest('.firmware-version-actions')) {
return; return;
@@ -299,42 +310,59 @@ class FirmwareComponent extends Component {
const name = item.getAttribute('data-name'); const name = item.getAttribute('data-name');
const version = item.getAttribute('data-version'); const version = item.getAttribute('data-version');
this.showEditFirmwareForm(name, version); this.showEditFirmwareForm(name, version);
}); };
item.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => item.removeEventListener('click', handler));
}); });
// Rollout buttons // Rollout buttons
const rolloutBtns = this.findAllElements('.rollout-btn'); const rolloutBtns = this.findAllElements('.rollout-btn');
rolloutBtns.forEach(btn => { rolloutBtns.forEach(btn => {
this.addEventListener(btn, 'click', (e) => { const handler = (e) => {
e.stopPropagation(); e.stopPropagation();
const name = btn.getAttribute('data-name'); const name = btn.getAttribute('data-name');
const version = btn.getAttribute('data-version'); const version = btn.getAttribute('data-version');
const labels = JSON.parse(btn.getAttribute('data-labels') || '{}'); const labels = JSON.parse(btn.getAttribute('data-labels') || '{}');
this.showRolloutPanel(name, version, labels); this.showRolloutPanel(name, version, labels);
}); };
btn.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
}); });
// Download buttons // Download buttons
const downloadBtns = this.findAllElements('.download-btn'); const downloadBtns = this.findAllElements('.download-btn');
downloadBtns.forEach(btn => { downloadBtns.forEach(btn => {
this.addEventListener(btn, 'click', (e) => { const handler = (e) => {
e.stopPropagation(); e.stopPropagation();
const name = btn.getAttribute('data-name'); const name = btn.getAttribute('data-name');
const version = btn.getAttribute('data-version'); const version = btn.getAttribute('data-version');
this.downloadFirmware(name, version); this.downloadFirmware(name, version);
}); };
btn.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
}); });
// Delete buttons // Delete buttons
const deleteBtns = this.findAllElements('.delete-btn'); const deleteBtns = this.findAllElements('.delete-btn');
logger.debug('Found delete buttons:', deleteBtns.length);
deleteBtns.forEach(btn => { deleteBtns.forEach(btn => {
this.addEventListener(btn, 'click', (e) => { const handler = (e) => {
e.stopPropagation(); e.stopPropagation();
const name = btn.getAttribute('data-name'); const name = btn.getAttribute('data-name');
const version = btn.getAttribute('data-version'); const version = btn.getAttribute('data-version');
logger.debug('Delete button clicked:', name, version);
this.showDeleteConfirmation(name, version); this.showDeleteConfirmation(name, version);
};
btn.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
}); });
}); }
cleanupDynamicListeners() {
if (this.dynamicUnsubscribers) {
this.dynamicUnsubscribers.forEach(unsub => unsub());
this.dynamicUnsubscribers = [];
}
} }
showAddFirmwareForm() { showAddFirmwareForm() {
@@ -398,13 +426,12 @@ class FirmwareComponent extends Component {
} }
showDeleteConfirmation(name, version) { showDeleteConfirmation(name, version) {
this.showConfirmationDialog({ OverlayDialogComponent.danger({
title: 'Delete Firmware', title: 'Delete Firmware',
message: `Are you sure you want to delete firmware "${name}" version "${version}"?<br><br>This action cannot be undone.`, message: `Are you sure you want to delete firmware "${name}" version "${version}"?<br><br>This action cannot be undone.`,
confirmText: 'Delete', confirmText: 'Delete',
cancelText: 'Cancel', cancelText: 'Cancel',
onConfirm: () => this.deleteFirmware(name, version), onConfirm: () => this.deleteFirmware(name, version)
onCancel: () => {}
}); });
} }
@@ -574,9 +601,9 @@ class FirmwareComponent extends Component {
async deleteFirmware(name, version) { async deleteFirmware(name, version) {
try { try {
// Note: The registry API doesn't have a delete endpoint in the OpenAPI spec await window.apiClient.deleteFirmwareFromRegistry(name, version);
// This would need to be implemented in the registry service this.showSuccess(`Firmware ${name} v${version} deleted successfully`);
this.showError('Delete functionality not yet implemented in registry API'); await this.loadFirmwareList();
} catch (error) { } catch (error) {
logger.error('Delete failed:', error); logger.error('Delete failed:', error);
this.showError('Delete failed: ' + error.message); this.showError('Delete failed: ' + error.message);
@@ -638,52 +665,6 @@ class FirmwareComponent extends Component {
} }
} }
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) { showSuccess(message) {
this.showNotification(message, 'success'); this.showNotification(message, 'success');

View File

@@ -9,6 +9,8 @@ class OverlayDialogComponent extends Component {
this.message = ''; this.message = '';
this.confirmText = 'Yes'; this.confirmText = 'Yes';
this.cancelText = 'No'; this.cancelText = 'No';
this.confirmClass = 'overlay-dialog-btn-confirm';
this.showCloseButton = true;
} }
mount() { mount() {
@@ -38,6 +40,8 @@ class OverlayDialogComponent extends Component {
message = 'Are you sure you want to proceed?', message = 'Are you sure you want to proceed?',
confirmText = 'Yes', confirmText = 'Yes',
cancelText = 'No', cancelText = 'No',
confirmClass = 'overlay-dialog-btn-confirm',
showCloseButton = true,
onConfirm = null, onConfirm = null,
onCancel = null onCancel = null
} = options; } = options;
@@ -46,53 +50,74 @@ class OverlayDialogComponent extends Component {
this.message = message; this.message = message;
this.confirmText = confirmText; this.confirmText = confirmText;
this.cancelText = cancelText; this.cancelText = cancelText;
this.confirmClass = confirmClass;
this.showCloseButton = showCloseButton;
this.onConfirm = onConfirm; this.onConfirm = onConfirm;
this.onCancel = onCancel; this.onCancel = onCancel;
this.render(); this.render();
// Add visible class with small delay for animation
setTimeout(() => {
this.container.classList.add('visible'); this.container.classList.add('visible');
}, 10);
this.isVisible = true; this.isVisible = true;
} }
hide() { hide() {
this.container.classList.remove('visible'); this.container.classList.remove('visible');
setTimeout(() => {
this.isVisible = false; this.isVisible = false;
// Call cancel callback if provided // Call cancel callback if provided
if (this.onCancel) { if (this.onCancel) {
this.onCancel(); this.onCancel();
this.onCancel = null;
} }
}, 300);
} }
handleConfirm() { handleConfirm() {
this.hide(); this.container.classList.remove('visible');
setTimeout(() => {
this.isVisible = false;
// Call confirm callback if provided // Call confirm callback if provided
if (this.onConfirm) { if (this.onConfirm) {
this.onConfirm(); this.onConfirm();
this.onConfirm = null;
} }
}, 300);
} }
render() { render() {
this.container.innerHTML = ` this.container.innerHTML = `
<div class="overlay-dialog-content"> <div class="overlay-dialog-content">
<div class="overlay-dialog-header"> <div class="overlay-dialog-header">
<h3 class="overlay-dialog-title">${this.title}</h3> <h3 class="overlay-dialog-title">${this.escapeHtml(this.title)}</h3>
<button class="overlay-dialog-close" type="button"> ${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"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M18 6L6 18M6 6l12 12"/> <line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
</button> </button>
` : ''}
</div> </div>
<div class="overlay-dialog-body"> <div class="overlay-dialog-body">
<div class="overlay-dialog-message">${this.message}</div> <p class="overlay-dialog-message">${this.message}</p>
</div> </div>
<div class="overlay-dialog-footer"> <div class="overlay-dialog-footer">
${this.cancelText ? `
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button"> <button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
${this.cancelText} ${this.escapeHtml(this.cancelText)}
</button> </button>
<button class="overlay-dialog-btn overlay-dialog-btn-confirm" type="button"> ` : ''}
${this.confirmText} <button class="overlay-dialog-btn ${this.confirmClass}" type="button">
${this.escapeHtml(this.confirmText)}
</button> </button>
</div> </div>
</div> </div>
@@ -101,7 +126,7 @@ class OverlayDialogComponent extends Component {
// Add event listeners to buttons // Add event listeners to buttons
const closeBtn = this.container.querySelector('.overlay-dialog-close'); const closeBtn = this.container.querySelector('.overlay-dialog-close');
const cancelBtn = this.container.querySelector('.overlay-dialog-btn-cancel'); 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) { if (closeBtn) {
this.addEventListener(closeBtn, 'click', () => this.hide()); 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() { unmount() {
// Clean up event listeners // Clean up event listeners
this.removeAllEventListeners(); this.removeAllEventListeners();
@@ -124,3 +156,68 @@ class OverlayDialogComponent extends Component {
super.unmount(); super.unmount();
} }
} }
// Static utility methods for easy usage without mounting
OverlayDialogComponent.show = function(options) {
// Create a temporary container
const container = document.createElement('div');
container.className = 'overlay-dialog';
document.body.appendChild(container);
// Create component instance
const dialog = new OverlayDialogComponent(container, null, null);
// Override hide to clean up container
const originalHide = dialog.hide.bind(dialog);
dialog.hide = function() {
originalHide();
setTimeout(() => {
if (container.parentNode) {
document.body.removeChild(container);
}
}, 350);
};
// Override handleConfirm to clean up container
const originalHandleConfirm = dialog.handleConfirm.bind(dialog);
dialog.handleConfirm = function() {
originalHandleConfirm();
setTimeout(() => {
if (container.parentNode) {
document.body.removeChild(container);
}
}, 350);
};
dialog.mount();
dialog.show(options);
return dialog;
};
// Convenience method for confirmation dialogs
OverlayDialogComponent.confirm = function(options) {
return OverlayDialogComponent.show({
...options,
confirmClass: options.confirmClass || 'overlay-dialog-btn-confirm'
});
};
// Convenience method for danger/delete confirmations
OverlayDialogComponent.danger = function(options) {
return OverlayDialogComponent.show({
...options,
confirmClass: 'overlay-dialog-btn-danger'
});
};
// Convenience method for alerts
OverlayDialogComponent.alert = function(message, title = 'Notice') {
return OverlayDialogComponent.show({
title,
message,
confirmText: 'OK',
cancelText: null,
showCloseButton: false
});
};

View File

@@ -228,6 +228,17 @@ class RolloutComponent extends Component {
return; 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 // Send the firmware info and matching nodes directly
const rolloutData = { const rolloutData = {
firmware: { firmware: {
@@ -240,6 +251,8 @@ class RolloutComponent extends Component {
this.onRolloutCallback(rolloutData); this.onRolloutCallback(rolloutData);
} }
});
}
handleCancel() { handleCancel() {
if (this.onCancelCallback) { if (this.onCancelCallback) {

View File

@@ -79,7 +79,6 @@ p {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 16px; border-radius: 16px;
backdrop-filter: var(--backdrop-blur); backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-primary);
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
padding: 0.75rem; padding: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -2140,7 +2139,6 @@ p {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 16px; border-radius: 16px;
backdrop-filter: var(--backdrop-blur); backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-primary);
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
padding: 0.75rem; padding: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -2319,7 +2317,6 @@ p {
.firmware-group.expanded .firmware-group-header { .firmware-group.expanded .firmware-group-header {
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-primary);
} }
.firmware-group-header-content { .firmware-group-header-content {
@@ -4485,7 +4482,6 @@ select.param-input:focus {
#topology-graph-container { #topology-graph-container {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
height: 100%; height: 100%;
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -5056,6 +5052,15 @@ select.param-input:focus {
background: rgba(244, 67, 54, 0.2); background: rgba(244, 67, 54, 0.2);
color: #f44336; color: #f44336;
border: 1px solid rgba(244, 67, 54, 0.3); border: 1px solid rgba(244, 67, 54, 0.3);
padding: 0.5rem 0.75rem !important;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 8px;
margin: 0;
} }
/* Ultra-compact layout for upload progress */ /* Ultra-compact layout for upload progress */
@@ -6480,7 +6485,6 @@ html {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 16px; border-radius: 16px;
backdrop-filter: var(--backdrop-blur); backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-primary);
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -7502,6 +7506,19 @@ html {
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
} }
.overlay-dialog-btn-danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border: 1px solid #ef4444;
color: white;
}
.overlay-dialog-btn-danger:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
border-color: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
/* === Rollout Component Styles === */ /* === Rollout Component Styles === */
.rollout-btn { .rollout-btn {
background: transparent; background: transparent;