feat: rollout
This commit is contained in:
@@ -53,6 +53,11 @@ class FirmwareComponent extends Component {
|
||||
logger.debug('FirmwareComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.cleanupDynamicListeners();
|
||||
super.unmount();
|
||||
}
|
||||
|
||||
async checkRegistryConnection() {
|
||||
try {
|
||||
await window.apiClient.getRegistryHealth();
|
||||
@@ -168,8 +173,13 @@ class FirmwareComponent extends Component {
|
||||
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 class="firmware-group-header-content">
|
||||
<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>
|
||||
<svg class="firmware-group-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="firmware-versions">
|
||||
${versionsHTML}
|
||||
@@ -198,6 +208,13 @@ class FirmwareComponent extends Component {
|
||||
</div>
|
||||
</div>
|
||||
<div class="firmware-version-actions">
|
||||
<button class="action-btn rollout-btn" title="Rollout firmware" data-name="${firmware.name}" data-version="${firmware.version}" data-labels='${JSON.stringify(firmware.labels)}'>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</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"/>
|
||||
@@ -266,10 +283,25 @@ class FirmwareComponent extends Component {
|
||||
}
|
||||
|
||||
setupFirmwareItemListeners() {
|
||||
// First, clean up existing listeners for dynamically created content
|
||||
this.cleanupDynamicListeners();
|
||||
this.dynamicUnsubscribers = [];
|
||||
|
||||
// Firmware group header clicks (for expand/collapse)
|
||||
const groupHeaders = this.findAllElements('.firmware-group-header');
|
||||
groupHeaders.forEach(header => {
|
||||
const handler = (e) => {
|
||||
const group = header.closest('.firmware-group');
|
||||
group.classList.toggle('expanded');
|
||||
};
|
||||
header.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => header.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Version item clicks (for editing)
|
||||
const versionItems = this.findAllElements('.firmware-version-item.clickable');
|
||||
versionItems.forEach(item => {
|
||||
this.addEventListener(item, 'click', (e) => {
|
||||
const handler = (e) => {
|
||||
// Don't trigger if clicking on action buttons
|
||||
if (e.target.closest('.firmware-version-actions')) {
|
||||
return;
|
||||
@@ -278,32 +310,61 @@ class FirmwareComponent extends Component {
|
||||
const name = item.getAttribute('data-name');
|
||||
const version = item.getAttribute('data-version');
|
||||
this.showEditFirmwareForm(name, version);
|
||||
});
|
||||
};
|
||||
item.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => item.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Rollout buttons
|
||||
const rolloutBtns = this.findAllElements('.rollout-btn');
|
||||
rolloutBtns.forEach(btn => {
|
||||
const handler = (e) => {
|
||||
e.stopPropagation();
|
||||
const name = btn.getAttribute('data-name');
|
||||
const version = btn.getAttribute('data-version');
|
||||
const labels = JSON.parse(btn.getAttribute('data-labels') || '{}');
|
||||
this.showRolloutPanel(name, version, labels);
|
||||
};
|
||||
btn.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Download buttons
|
||||
const downloadBtns = this.findAllElements('.download-btn');
|
||||
downloadBtns.forEach(btn => {
|
||||
this.addEventListener(btn, 'click', (e) => {
|
||||
const handler = (e) => {
|
||||
e.stopPropagation();
|
||||
const name = btn.getAttribute('data-name');
|
||||
const version = btn.getAttribute('data-version');
|
||||
this.downloadFirmware(name, version);
|
||||
});
|
||||
};
|
||||
btn.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Delete buttons
|
||||
const deleteBtns = this.findAllElements('.delete-btn');
|
||||
logger.debug('Found delete buttons:', deleteBtns.length);
|
||||
deleteBtns.forEach(btn => {
|
||||
this.addEventListener(btn, 'click', (e) => {
|
||||
const handler = (e) => {
|
||||
e.stopPropagation();
|
||||
const name = btn.getAttribute('data-name');
|
||||
const version = btn.getAttribute('data-version');
|
||||
logger.debug('Delete button clicked:', 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() {
|
||||
this.openFirmwareForm('Add Firmware', null, null);
|
||||
}
|
||||
@@ -365,21 +426,184 @@ class FirmwareComponent extends Component {
|
||||
}
|
||||
|
||||
showDeleteConfirmation(name, version) {
|
||||
this.showConfirmationDialog({
|
||||
OverlayDialogComponent.danger({
|
||||
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: () => {}
|
||||
onConfirm: () => this.deleteFirmware(name, version)
|
||||
});
|
||||
}
|
||||
|
||||
async showRolloutPanel(name, version, labels) {
|
||||
try {
|
||||
// Get cluster node versions to show which nodes will be affected
|
||||
const nodeVersions = await window.apiClient.getClusterNodeVersions();
|
||||
|
||||
// Filter nodes that match the firmware labels
|
||||
const matchingNodes = nodeVersions.nodes.filter(node => {
|
||||
return this.nodeMatchesLabels(node.labels, labels);
|
||||
});
|
||||
|
||||
this.openRolloutDrawer(name, version, labels, matchingNodes);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get cluster node versions:', error);
|
||||
this.showError('Failed to get cluster information: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
nodeMatchesLabels(nodeLabels, firmwareLabels) {
|
||||
for (const [key, value] of Object.entries(firmwareLabels)) {
|
||||
if (!nodeLabels[key] || nodeLabels[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
openRolloutDrawer(name, version, labels, matchingNodes) {
|
||||
this.drawer.openDrawer('Rollout Firmware', (contentContainer, setActiveComponent) => {
|
||||
const rolloutComponent = new RolloutComponent(contentContainer, this.viewModel, this.eventBus);
|
||||
setActiveComponent(rolloutComponent);
|
||||
|
||||
rolloutComponent.setRolloutData(name, version, labels, matchingNodes);
|
||||
rolloutComponent.setOnRolloutCallback((rolloutData) => {
|
||||
this.startRollout(rolloutData);
|
||||
});
|
||||
rolloutComponent.setOnCancelCallback(() => {
|
||||
this.drawer.closeDrawer();
|
||||
});
|
||||
|
||||
// Store reference for status updates
|
||||
this.currentRolloutComponent = rolloutComponent;
|
||||
|
||||
rolloutComponent.mount();
|
||||
}, null, null, true); // Hide terminal button
|
||||
}
|
||||
|
||||
async startRollout(rolloutData) {
|
||||
try {
|
||||
// Start rollout in the panel (no backdrop)
|
||||
if (this.currentRolloutComponent) {
|
||||
this.currentRolloutComponent.startRollout();
|
||||
}
|
||||
|
||||
const response = await window.apiClient.startRollout(rolloutData);
|
||||
|
||||
logger.info('Rollout started:', response);
|
||||
this.showSuccess(`Rollout started for ${response.totalNodes} nodes`);
|
||||
|
||||
// Set up WebSocket listener for rollout progress
|
||||
this.setupRolloutProgressListener(response.rolloutId);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Rollout failed:', error);
|
||||
this.showError('Rollout failed: ' + error.message);
|
||||
|
||||
// Reset rollout state on error
|
||||
if (this.currentRolloutComponent) {
|
||||
this.currentRolloutComponent.resetRolloutState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showRolloutProgress() {
|
||||
// Create backdrop and progress overlay
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'rollout-backdrop';
|
||||
backdrop.id = 'rollout-backdrop';
|
||||
|
||||
const progressOverlay = document.createElement('div');
|
||||
progressOverlay.className = 'rollout-progress-overlay';
|
||||
progressOverlay.innerHTML = `
|
||||
<div class="rollout-progress-content">
|
||||
<div class="rollout-progress-header">
|
||||
<h3>Rolling Out Firmware</h3>
|
||||
</div>
|
||||
<div class="rollout-progress-body">
|
||||
<div class="rollout-progress-info">
|
||||
<p>Firmware rollout in progress...</p>
|
||||
<p class="rollout-progress-text">Preparing rollout...</p>
|
||||
</div>
|
||||
<div class="rollout-progress-bar">
|
||||
<div class="rollout-progress-fill" id="rollout-progress-fill"></div>
|
||||
</div>
|
||||
<div class="rollout-progress-details" id="rollout-progress-details">
|
||||
<div class="rollout-node-list" id="rollout-node-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(progressOverlay);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
// Block UI interactions
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
hideRolloutProgress() {
|
||||
const backdrop = document.getElementById('rollout-backdrop');
|
||||
if (backdrop) {
|
||||
document.body.removeChild(backdrop);
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
setupRolloutProgressListener(rolloutId) {
|
||||
// Track completed nodes for parallel processing
|
||||
this.completedNodes = new Set();
|
||||
this.totalNodes = 0;
|
||||
|
||||
const progressListener = (data) => {
|
||||
if (data.rolloutId === rolloutId) {
|
||||
// Set total nodes from first update
|
||||
if (this.totalNodes === 0) {
|
||||
this.totalNodes = data.total;
|
||||
}
|
||||
this.updateRolloutProgress(data);
|
||||
}
|
||||
};
|
||||
|
||||
window.wsClient.on('rolloutProgress', progressListener);
|
||||
|
||||
// Store listener for cleanup
|
||||
this.currentRolloutListener = progressListener;
|
||||
}
|
||||
|
||||
updateRolloutProgress(data) {
|
||||
// Update status in the rollout panel
|
||||
if (this.currentRolloutComponent) {
|
||||
this.currentRolloutComponent.updateNodeStatus(data.nodeIp, data.status);
|
||||
}
|
||||
|
||||
// Track completed nodes for parallel processing
|
||||
if (data.status === 'completed') {
|
||||
this.completedNodes.add(data.nodeIp);
|
||||
} else if (data.status === 'failed') {
|
||||
// Also count failed nodes as "processed" for completion check
|
||||
this.completedNodes.add(data.nodeIp);
|
||||
}
|
||||
|
||||
// Check if rollout is complete (all nodes processed)
|
||||
if (this.completedNodes.size >= this.totalNodes) {
|
||||
setTimeout(() => {
|
||||
this.showSuccess('Rollout completed successfully');
|
||||
|
||||
// Clean up WebSocket listener
|
||||
if (this.currentRolloutListener) {
|
||||
window.wsClient.off('rolloutProgress', this.currentRolloutListener);
|
||||
this.currentRolloutListener = null;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
await window.apiClient.deleteFirmwareFromRegistry(name, version);
|
||||
this.showSuccess(`Firmware ${name} v${version} deleted successfully`);
|
||||
await this.loadFirmwareList();
|
||||
} catch (error) {
|
||||
logger.error('Delete failed:', error);
|
||||
this.showError('Delete failed: ' + error.message);
|
||||
@@ -441,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) {
|
||||
this.showNotification(message, 'success');
|
||||
|
||||
Reference in New Issue
Block a user