feat: rollout
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
271
public/scripts/components/RolloutComponent.js
Normal file
271
public/scripts/components/RolloutComponent.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user