feat: rollout

This commit is contained in:
2025-10-21 21:01:56 +02:00
parent 7def7bce81
commit 0b3f0cbca4
6 changed files with 854 additions and 83 deletions

View File

@@ -168,8 +168,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 +203,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,6 +278,15 @@ class FirmwareComponent extends Component {
}
setupFirmwareItemListeners() {
// Firmware group header clicks (for expand/collapse)
const groupHeaders = this.findAllElements('.firmware-group-header');
groupHeaders.forEach(header => {
this.addEventListener(header, 'click', (e) => {
const group = header.closest('.firmware-group');
group.classList.toggle('expanded');
});
});
// Version item clicks (for editing)
const versionItems = this.findAllElements('.firmware-version-item.clickable');
versionItems.forEach(item => {
@@ -281,6 +302,18 @@ class FirmwareComponent extends Component {
});
});
// Rollout buttons
const rolloutBtns = this.findAllElements('.rollout-btn');
rolloutBtns.forEach(btn => {
this.addEventListener(btn, 'click', (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);
});
});
// Download buttons
const downloadBtns = this.findAllElements('.download-btn');
downloadBtns.forEach(btn => {
@@ -375,6 +408,170 @@ class FirmwareComponent extends Component {
});
}
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