feat: rollout

This commit is contained in:
2025-10-21 21:01:56 +02:00
parent 7def7bce81
commit cdb42c459a
7 changed files with 1059 additions and 175 deletions

View File

@@ -248,6 +248,7 @@
<script src="./scripts/components/FirmwareComponent.js"></script>
<script src="./scripts/components/FirmwareFormComponent.js"></script>
<script src="./scripts/components/FirmwareUploadComponent.js"></script>
<script src="./scripts/components/RolloutComponent.js"></script>
<script src="./scripts/components/WiFiConfigComponent.js"></script>
<!-- Container/view components after their deps -->
<script src="./scripts/components/FirmwareViewComponent.js"></script>

View File

@@ -137,77 +137,42 @@ class ApiClient {
});
}
// Registry API methods
async getRegistryBaseUrl() {
// Auto-detect registry server URL based on current location
const currentHost = window.location.hostname;
// If accessing from localhost, use localhost:8080
// If accessing from another device, use the same hostname but port 8080
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
return 'http://localhost:8080';
} else {
return `http://${currentHost}:8080`;
}
// Registry API methods - now proxied through gateway
async getRegistryHealth() {
return this.request('/api/registry/health', { method: 'GET' });
}
async uploadFirmwareToRegistry(metadata, firmwareFile) {
const registryBaseUrl = await this.getRegistryBaseUrl();
const formData = new FormData();
formData.append('metadata', JSON.stringify(metadata));
formData.append('firmware', firmwareFile);
const response = await fetch(`${registryBaseUrl}/firmware`, {
return this.request('/api/registry/firmware', {
method: 'POST',
body: formData
body: formData,
isForm: true,
headers: {}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Registry upload failed: ${errorText}`);
}
return await response.json();
}
async updateFirmwareMetadata(name, version, metadata) {
const registryBaseUrl = await this.getRegistryBaseUrl();
const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(metadata)
body: metadata
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Registry metadata update failed: ${errorText}`);
}
return await response.json();
}
async listFirmwareFromRegistry(name = null, version = null) {
const registryBaseUrl = await this.getRegistryBaseUrl();
const query = {};
if (name) query.name = name;
if (version) query.version = version;
const response = await fetch(`${registryBaseUrl}/firmware${Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : ''}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Registry list failed: ${errorText}`);
}
return await response.json();
const queryString = Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : '';
return this.request(`/api/registry/firmware${queryString}`, { method: 'GET' });
}
async downloadFirmwareFromRegistry(name, version) {
const registryBaseUrl = await this.getRegistryBaseUrl();
const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
const response = await fetch(`${this.baseURL}/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
if (!response.ok) {
const errorText = await response.text();
@@ -217,16 +182,22 @@ class ApiClient {
return response.blob();
}
async getRegistryHealth() {
const registryBaseUrl = await this.getRegistryBaseUrl();
const response = await fetch(`${registryBaseUrl}/health`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Registry health check failed: ${errorText}`);
}
return await response.json();
async deleteFirmwareFromRegistry(name, version) {
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
method: 'DELETE'
});
}
// Rollout API methods
async getClusterNodeVersions() {
return this.request('/api/cluster/node/versions', { method: 'GET' });
}
async startRollout(rolloutData) {
return this.request('/api/rollout', {
method: 'POST',
body: rolloutData
});
}
}
@@ -320,6 +291,9 @@ class WebSocketClient {
case 'firmware_upload_status':
this.emit('firmwareUploadStatus', data);
break;
case 'rollout_progress':
this.emit('rolloutProgress', data);
break;
default:
logger.debug('Unknown WebSocket message type:', data.type);
}

View File

@@ -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');

View File

@@ -9,6 +9,8 @@ class OverlayDialogComponent extends Component {
this.message = '';
this.confirmText = 'Yes';
this.cancelText = 'No';
this.confirmClass = 'overlay-dialog-btn-confirm';
this.showCloseButton = true;
}
mount() {
@@ -38,6 +40,8 @@ class OverlayDialogComponent extends Component {
message = 'Are you sure you want to proceed?',
confirmText = 'Yes',
cancelText = 'No',
confirmClass = 'overlay-dialog-btn-confirm',
showCloseButton = true,
onConfirm = null,
onCancel = null
} = options;
@@ -46,53 +50,74 @@ class OverlayDialogComponent extends Component {
this.message = message;
this.confirmText = confirmText;
this.cancelText = cancelText;
this.confirmClass = confirmClass;
this.showCloseButton = showCloseButton;
this.onConfirm = onConfirm;
this.onCancel = onCancel;
this.render();
this.container.classList.add('visible');
// Add visible class with small delay for animation
setTimeout(() => {
this.container.classList.add('visible');
}, 10);
this.isVisible = true;
}
hide() {
this.container.classList.remove('visible');
this.isVisible = false;
// Call cancel callback if provided
if (this.onCancel) {
this.onCancel();
}
setTimeout(() => {
this.isVisible = false;
// Call cancel callback if provided
if (this.onCancel) {
this.onCancel();
this.onCancel = null;
}
}, 300);
}
handleConfirm() {
this.hide();
this.container.classList.remove('visible');
// Call confirm callback if provided
if (this.onConfirm) {
this.onConfirm();
}
setTimeout(() => {
this.isVisible = false;
// Call confirm callback if provided
if (this.onConfirm) {
this.onConfirm();
this.onConfirm = null;
}
}, 300);
}
render() {
this.container.innerHTML = `
<div class="overlay-dialog-content">
<div class="overlay-dialog-header">
<h3 class="overlay-dialog-title">${this.title}</h3>
<button class="overlay-dialog-close" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<h3 class="overlay-dialog-title">${this.escapeHtml(this.title)}</h3>
${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">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
` : ''}
</div>
<div class="overlay-dialog-body">
<div class="overlay-dialog-message">${this.message}</div>
<p class="overlay-dialog-message">${this.message}</p>
</div>
<div class="overlay-dialog-footer">
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
${this.cancelText}
</button>
<button class="overlay-dialog-btn overlay-dialog-btn-confirm" type="button">
${this.confirmText}
${this.cancelText ? `
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
${this.escapeHtml(this.cancelText)}
</button>
` : ''}
<button class="overlay-dialog-btn ${this.confirmClass}" type="button">
${this.escapeHtml(this.confirmText)}
</button>
</div>
</div>
@@ -101,7 +126,7 @@ class OverlayDialogComponent extends Component {
// Add event listeners to buttons
const closeBtn = this.container.querySelector('.overlay-dialog-close');
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) {
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() {
// Clean up event listeners
this.removeAllEventListeners();
@@ -124,3 +156,68 @@ class OverlayDialogComponent extends Component {
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

@@ -0,0 +1,271 @@
// Rollout Component - Shows rollout panel with matching nodes and starts rollout
class RolloutComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('RolloutComponent: Constructor called');
this.rolloutData = null;
this.matchingNodes = [];
this.onRolloutCallback = null;
this.onCancelCallback = null;
}
setupEventListeners() {
// Rollout button
const rolloutBtn = this.findElement('#rollout-confirm-btn');
if (rolloutBtn) {
this.addEventListener(rolloutBtn, 'click', this.handleRollout.bind(this));
}
// Cancel button
const cancelBtn = this.findElement('#rollout-cancel-btn');
if (cancelBtn) {
this.addEventListener(cancelBtn, 'click', this.handleCancel.bind(this));
}
}
// Start rollout - hide labels and show status indicators
startRollout() {
const nodeItems = this.container.querySelectorAll('.rollout-node-item');
nodeItems.forEach(item => {
const labelsDiv = item.querySelector('.rollout-node-labels');
const statusDiv = item.querySelector('.status-indicator');
if (labelsDiv && statusDiv) {
labelsDiv.style.display = 'none';
statusDiv.style.display = 'block';
statusDiv.textContent = 'Ready';
statusDiv.className = 'status-indicator ready';
}
});
// Disable the confirm button
const confirmBtn = this.findElement('#rollout-confirm-btn');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Rollout in Progress...';
}
}
// Update status for a specific node
updateNodeStatus(nodeIp, status) {
const nodeItem = this.container.querySelector(`[data-node-ip="${nodeIp}"]`);
if (!nodeItem) return;
const statusDiv = nodeItem.querySelector('.status-indicator');
if (!statusDiv) return;
let displayStatus = status;
let statusClass = '';
switch (status) {
case 'updating_labels':
displayStatus = 'Updating Labels...';
statusClass = 'uploading';
break;
case 'uploading':
displayStatus = 'Uploading...';
statusClass = 'uploading';
break;
case 'completed':
displayStatus = 'Completed';
statusClass = 'success';
break;
case 'failed':
displayStatus = 'Failed';
statusClass = 'error';
break;
default:
displayStatus = status;
statusClass = 'pending';
}
statusDiv.textContent = displayStatus;
statusDiv.className = `status-indicator ${statusClass}`;
}
// Check if rollout is complete
isRolloutComplete() {
const statusIndicators = this.container.querySelectorAll('.status-indicator');
for (const indicator of statusIndicators) {
const status = indicator.textContent.toLowerCase();
if (status !== 'completed' && status !== 'failed') {
return false;
}
}
return true;
}
// Reset to initial state (show labels, hide status indicators)
resetRolloutState() {
const nodeItems = this.container.querySelectorAll('.rollout-node-item');
nodeItems.forEach(item => {
const labelsDiv = item.querySelector('.rollout-node-labels');
const statusDiv = item.querySelector('.status-indicator');
if (labelsDiv && statusDiv) {
labelsDiv.style.display = 'block';
statusDiv.style.display = 'none';
}
});
// Re-enable the confirm button
const confirmBtn = this.findElement('#rollout-confirm-btn');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = `Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}`;
}
}
mount() {
super.mount();
logger.debug('RolloutComponent: Mounting...');
this.render();
logger.debug('RolloutComponent: Mounted successfully');
}
setRolloutData(name, version, labels, matchingNodes) {
this.rolloutData = { name, version, labels };
this.matchingNodes = matchingNodes;
}
setOnRolloutCallback(callback) {
this.onRolloutCallback = callback;
}
setOnCancelCallback(callback) {
this.onCancelCallback = callback;
}
render() {
if (!this.rolloutData) {
this.container.innerHTML = '<div class="error">No rollout data available</div>';
return;
}
const { name, version, labels } = this.rolloutData;
// Render labels as chips
const labelsHTML = Object.entries(labels).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
// Render matching nodes
const nodesHTML = this.matchingNodes.map(node => {
const nodeLabelsHTML = Object.entries(node.labels || {}).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
return `
<div class="rollout-node-item" data-node-ip="${node.ip}">
<div class="rollout-node-info">
<div class="rollout-node-ip">${this.escapeHtml(node.ip)}</div>
<div class="rollout-node-version">Version: ${this.escapeHtml(node.version)}</div>
</div>
<div class="rollout-node-status">
<div class="rollout-node-labels">
${nodeLabelsHTML}
</div>
<div class="status-indicator ready" style="display: none;">Ready</div>
</div>
</div>
`;
}).join('');
this.container.innerHTML = `
<div class="rollout-panel">
<div class="rollout-header">
<p>Deploy firmware to matching cluster nodes</p>
</div>
<div class="rollout-firmware-info">
<div class="rollout-firmware-name">${this.escapeHtml(name)}</div>
<div class="rollout-firmware-version">Version: ${this.escapeHtml(version)}</div>
<div class="rollout-firmware-labels">
${labelsHTML}
</div>
</div>
<div class="rollout-matching-nodes">
<h4>Matching Nodes (${this.matchingNodes.length})</h4>
<div class="rollout-nodes-list">
${nodesHTML}
</div>
</div>
<div class="rollout-warning">
<div class="warning-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="warning-text">
<strong>Warning:</strong> This will update firmware on ${this.matchingNodes.length} node${this.matchingNodes.length !== 1 ? 's' : ''}.
The rollout process cannot be cancelled once started.
</div>
</div>
<div class="rollout-actions">
<button id="rollout-cancel-btn" class="refresh-btn">Cancel</button>
<button id="rollout-confirm-btn" class="deploy-btn" ${this.matchingNodes.length === 0 ? 'disabled' : ''}>
Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}
</button>
</div>
</div>
`;
this.setupEventListeners();
}
handleRollout() {
if (!this.onRolloutCallback || this.matchingNodes.length === 0) {
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
const rolloutData = {
firmware: {
name: this.rolloutData.name,
version: this.rolloutData.version,
labels: this.rolloutData.labels
},
nodes: this.matchingNodes
};
this.onRolloutCallback(rolloutData);
}
});
}
handleCancel() {
if (this.onCancelCallback) {
this.onCancelCallback();
}
}
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
window.RolloutComponent = RolloutComponent;

View File

@@ -79,7 +79,6 @@ p {
background: var(--bg-secondary);
border-radius: 16px;
backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-primary);
border: 1px solid var(--border-primary);
padding: 0.75rem;
margin-bottom: 1rem;
@@ -701,10 +700,6 @@ p {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
}
.label-key-input,
@@ -738,7 +733,7 @@ p {
.labels-container {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -1954,7 +1949,7 @@ p {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #ffcdd2;
padding: 1rem;
/*padding: 1rem;*/
border-radius: 8px;
margin-top: 1rem;
}
@@ -2144,7 +2139,6 @@ p {
background: var(--bg-secondary);
border-radius: 16px;
backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-primary);
border: 1px solid var(--border-primary);
padding: 0.75rem;
margin-bottom: 1rem;
@@ -2269,7 +2263,6 @@ p {
.firmware-groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.firmware-group {
@@ -2312,9 +2305,24 @@ p {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
cursor: pointer;
position: relative;
z-index: 2;
user-select: none;
}
.firmware-group.expanded .firmware-group-header {
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-primary);
}
.firmware-group-header-content {
display: flex;
align-items: center;
gap: 1rem;
}
.firmware-group-name {
@@ -2333,10 +2341,23 @@ p {
font-weight: 500;
}
.firmware-group-chevron {
color: var(--text-secondary);
transition: transform 0.3s ease;
flex-shrink: 0;
}
.firmware-group.expanded .firmware-group-chevron {
transform: rotate(180deg);
}
.firmware-versions {
display: flex;
display: none;
flex-direction: column;
gap: 0.75rem;
}
.firmware-group.expanded .firmware-versions {
display: flex;
}
.firmware-version-item {
@@ -2594,9 +2615,9 @@ p {
}
.action-btn.download-btn:hover {
background: rgba(34, 197, 94, 0.1);
border-color: #22c55e;
color: #22c55e;
background: rgba(96, 165, 250, 0.1);
border-color: var(--accent-secondary);
color: var(--accent-secondary);
}
.action-btn.delete-btn:hover {
@@ -4005,7 +4026,6 @@ select.param-input:focus {
gap: 0.25rem;
padding: 0.25rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: var(--backdrop-blur);
border-bottom: none;
@@ -4092,7 +4112,6 @@ select.param-input:focus {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 0.75rem;
background: var(--bg-tertiary);
backdrop-filter: var(--backdrop-blur);
}
@@ -4123,7 +4142,6 @@ select.param-input:focus {
}
.tabs-header {
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.14);
}
@@ -4148,7 +4166,6 @@ select.param-input:focus {
.tab-content {
border: 1px solid var(--border-primary);
background: rgba(255, 255, 255, 0.08);
}
/* Active tab: no background or border (keep underline) */
@@ -4461,7 +4478,6 @@ select.param-input:focus {
#topology-graph-container {
background: var(--bg-tertiary);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
height: 100%;
width: 100%;
margin: 0;
@@ -4934,27 +4950,32 @@ select.param-input:focus {
}
.target-nodes-section h3 {
margin: 0 0 1.5rem 0;
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.target-nodes-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
}
.target-node-item {
padding: 0.75rem;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0.7rem;
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--border-primary);
gap: 1.5rem;
align-items: center;
background: transparent;
border-radius: 0;
gap: 0;
}
.target-node-item:last-child {
border-bottom: none;
}
.target-node-item .node-info {
@@ -4995,7 +5016,6 @@ select.param-input:focus {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 100px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@@ -5028,6 +5048,15 @@ select.param-input:focus {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
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 */
@@ -6452,7 +6481,6 @@ html {
background: var(--bg-secondary);
border-radius: 16px;
backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-primary);
border: 1px solid var(--border-primary);
padding: 1rem;
margin-bottom: 1rem;
@@ -6713,6 +6741,9 @@ html {
.node-labels {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.75rem;
}
@@ -6733,7 +6764,7 @@ html {
.label-chip {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
background: rgba(30, 58, 138, 0.35);
@@ -7473,3 +7504,335 @@ html {
transform: translateY(-1px);
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-btn {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-secondary);
border-radius: 6px;
padding: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.rollout-btn:hover {
background: rgba(34, 197, 94, 0.1);
border-color: #22c55e;
color: #22c55e;
}
.rollout-btn:active {
transform: translateY(0);
}
.rollout-panel {
padding: 1.5rem;
max-width: 600px;
}
.rollout-header {
margin-bottom: 1.5rem;
}
.rollout-header h3 {
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.rollout-header p {
color: var(--text-secondary);
margin-bottom: 0;
}
.rollout-firmware-info {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--border-primary);
}
.rollout-firmware-name {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.rollout-firmware-version {
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.rollout-firmware-labels {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.rollout-matching-nodes {
margin-bottom: 1.5rem;
}
.rollout-matching-nodes h4 {
margin-bottom: 0.75rem;
color: var(--text-primary);
}
.rollout-nodes-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
}
.rollout-node-item {
padding: 0.75rem;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.rollout-node-item:last-child {
border-bottom: none;
}
.rollout-node-info {
flex: 1;
}
.rollout-node-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
min-width: 140px;
text-align: right;
}
.rollout-node-ip {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.rollout-node-version {
font-size: 0.9rem;
color: var(--text-secondary);
}
.rollout-node-labels {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.label-chip.small {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.rollout-warning {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.warning-icon {
color: var(--warning-color);
flex-shrink: 0;
margin-top: 0.125rem;
}
.warning-text {
color: var(--accent-warning);
line-height: 1.5;
}
.rollout-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
}
/* Rollout confirm button - make it look important */
.rollout-actions .deploy-btn {
background: linear-gradient(135deg, rgba(74, 222, 128, 0.2) 0%, rgba(74, 222, 128, 0.1) 100%);
border: 1px solid rgba(74, 222, 128, 0.3);
color: #41cb6d;
font-weight: 500;
padding: 0.75rem 1.25rem;
font-size: 0.9rem;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.rollout-actions .deploy-btn:hover {
background: linear-gradient(135deg, rgba(74, 222, 128, 0.3) 0%, rgba(74, 222, 128, 0.15) 100%);
border-color: rgba(74, 222, 128, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(74, 222, 128, 0.2);
}
.rollout-actions .deploy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Rollout Progress Overlay */
.rollout-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.rollout-progress-overlay {
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-primary);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: hidden;
}
.rollout-progress-content {
padding: 2rem;
}
.rollout-progress-header {
text-align: center;
margin-bottom: 1.5rem;
}
.rollout-progress-header h3 {
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.rollout-progress-body {
text-align: center;
}
.rollout-progress-info {
margin-bottom: 1.5rem;
}
.rollout-progress-info p {
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.rollout-progress-text {
font-weight: 600;
color: var(--text-primary);
}
.rollout-progress-bar {
width: 100%;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.rollout-progress-fill {
height: 100%;
background: var(--accent-primary);
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
.rollout-progress-details {
text-align: left;
}
.rollout-node-list {
overflow-y: auto;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
}
.rollout-node-item {
padding: 0.75rem;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.rollout-node-item:last-child {
border-bottom: none;
}
.rollout-node-ip {
font-weight: 600;
color: var(--text-primary);
}
.rollout-node-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.rollout-node-status.status-updating_labels {
background: var(--warning-bg);
color: var(--warning-color);
}
.rollout-node-status.status-uploading {
background: var(--info-bg);
color: var(--info-color);
}
.rollout-node-status.status-completed {
background: var(--success-bg);
color: var(--success-color);
}
.rollout-node-status.status-failed {
background: var(--error-bg);
color: var(--error-color);
}

View File

@@ -16,7 +16,7 @@
--border-secondary: rgba(255, 255, 255, 0.15);
--border-hover: rgba(255, 255, 255, 0.2);
--accent-primary: #4ade80;
--accent-primary: #1d8b45;
--accent-secondary: #60a5fa;
--accent-warning: #fbbf24;
--accent-error: #f87171;