Compare commits
1 Commits
0b3f0cbca4
...
a070898cab
| Author | SHA1 | Date | |
|---|---|---|---|
| a070898cab |
@@ -248,6 +248,7 @@
|
|||||||
<script src="./scripts/components/FirmwareComponent.js"></script>
|
<script src="./scripts/components/FirmwareComponent.js"></script>
|
||||||
<script src="./scripts/components/FirmwareFormComponent.js"></script>
|
<script src="./scripts/components/FirmwareFormComponent.js"></script>
|
||||||
<script src="./scripts/components/FirmwareUploadComponent.js"></script>
|
<script src="./scripts/components/FirmwareUploadComponent.js"></script>
|
||||||
|
<script src="./scripts/components/RolloutComponent.js"></script>
|
||||||
<script src="./scripts/components/WiFiConfigComponent.js"></script>
|
<script src="./scripts/components/WiFiConfigComponent.js"></script>
|
||||||
<!-- Container/view components after their deps -->
|
<!-- Container/view components after their deps -->
|
||||||
<script src="./scripts/components/FirmwareViewComponent.js"></script>
|
<script src="./scripts/components/FirmwareViewComponent.js"></script>
|
||||||
|
|||||||
@@ -137,77 +137,40 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry API methods
|
// Registry API methods - now proxied through gateway
|
||||||
async getRegistryBaseUrl() {
|
async getRegistryHealth() {
|
||||||
// Auto-detect registry server URL based on current location
|
return this.request('/api/registry/health', { method: 'GET' });
|
||||||
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`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFirmwareToRegistry(metadata, firmwareFile) {
|
async uploadFirmwareToRegistry(metadata, firmwareFile) {
|
||||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('metadata', JSON.stringify(metadata));
|
formData.append('metadata', JSON.stringify(metadata));
|
||||||
formData.append('firmware', firmwareFile);
|
formData.append('firmware', firmwareFile);
|
||||||
|
|
||||||
const response = await fetch(`${registryBaseUrl}/firmware`, {
|
return this.request('/api/registry/firmware', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Registry upload failed: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFirmwareMetadata(name, version, metadata) {
|
async updateFirmwareMetadata(name, version, metadata) {
|
||||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
|
||||||
|
|
||||||
const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
body: metadata
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(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) {
|
async listFirmwareFromRegistry(name = null, version = null) {
|
||||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
|
||||||
const query = {};
|
const query = {};
|
||||||
if (name) query.name = name;
|
if (name) query.name = name;
|
||||||
if (version) query.version = version;
|
if (version) query.version = version;
|
||||||
|
|
||||||
const response = await fetch(`${registryBaseUrl}/firmware${Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : ''}`);
|
const queryString = Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : '';
|
||||||
|
return this.request(`/api/registry/firmware${queryString}`, { method: 'GET' });
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Registry list failed: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFirmwareFromRegistry(name, version) {
|
async downloadFirmwareFromRegistry(name, version) {
|
||||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
const response = await fetch(`${this.baseURL}/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
|
||||||
const response = await fetch(`${registryBaseUrl}/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@@ -217,16 +180,16 @@ class ApiClient {
|
|||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRegistryHealth() {
|
// Rollout API methods
|
||||||
const registryBaseUrl = await this.getRegistryBaseUrl();
|
async getClusterNodeVersions() {
|
||||||
const response = await fetch(`${registryBaseUrl}/health`);
|
return this.request('/api/cluster/node/versions', { method: 'GET' });
|
||||||
|
}
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
async startRollout(rolloutData) {
|
||||||
throw new Error(`Registry health check failed: ${errorText}`);
|
return this.request('/api/rollout', {
|
||||||
}
|
method: 'POST',
|
||||||
|
body: rolloutData
|
||||||
return await response.json();
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +283,9 @@ class WebSocketClient {
|
|||||||
case 'firmware_upload_status':
|
case 'firmware_upload_status':
|
||||||
this.emit('firmwareUploadStatus', data);
|
this.emit('firmwareUploadStatus', data);
|
||||||
break;
|
break;
|
||||||
|
case 'rollout_progress':
|
||||||
|
this.emit('rolloutProgress', data);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
logger.debug('Unknown WebSocket message type:', data.type);
|
logger.debug('Unknown WebSocket message type:', data.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,8 +168,13 @@ class FirmwareComponent extends Component {
|
|||||||
return `
|
return `
|
||||||
<div class="firmware-group">
|
<div class="firmware-group">
|
||||||
<div class="firmware-group-header">
|
<div class="firmware-group-header">
|
||||||
<h3 class="firmware-group-name">${this.escapeHtml(group.name)}</h3>
|
<div class="firmware-group-header-content">
|
||||||
<span class="firmware-group-count">${group.firmware.length} version${group.firmware.length !== 1 ? 's' : ''}</span>
|
<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>
|
||||||
<div class="firmware-versions">
|
<div class="firmware-versions">
|
||||||
${versionsHTML}
|
${versionsHTML}
|
||||||
@@ -198,6 +203,13 @@ class FirmwareComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="firmware-version-actions">
|
<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}">
|
<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">
|
<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"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
@@ -266,6 +278,15 @@ class FirmwareComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupFirmwareItemListeners() {
|
setupFirmwareItemListeners() {
|
||||||
|
// Firmware group header clicks (for expand/collapse)
|
||||||
|
const groupHeaders = this.findAllElements('.firmware-group-header');
|
||||||
|
groupHeaders.forEach(header => {
|
||||||
|
this.addEventListener(header, 'click', (e) => {
|
||||||
|
const group = header.closest('.firmware-group');
|
||||||
|
group.classList.toggle('expanded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Version item clicks (for editing)
|
// Version item clicks (for editing)
|
||||||
const versionItems = this.findAllElements('.firmware-version-item.clickable');
|
const versionItems = this.findAllElements('.firmware-version-item.clickable');
|
||||||
versionItems.forEach(item => {
|
versionItems.forEach(item => {
|
||||||
@@ -281,6 +302,18 @@ class FirmwareComponent extends Component {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rollout buttons
|
||||||
|
const rolloutBtns = this.findAllElements('.rollout-btn');
|
||||||
|
rolloutBtns.forEach(btn => {
|
||||||
|
this.addEventListener(btn, 'click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const name = btn.getAttribute('data-name');
|
||||||
|
const version = btn.getAttribute('data-version');
|
||||||
|
const labels = JSON.parse(btn.getAttribute('data-labels') || '{}');
|
||||||
|
this.showRolloutPanel(name, version, labels);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Download buttons
|
// Download buttons
|
||||||
const downloadBtns = this.findAllElements('.download-btn');
|
const downloadBtns = this.findAllElements('.download-btn');
|
||||||
downloadBtns.forEach(btn => {
|
downloadBtns.forEach(btn => {
|
||||||
@@ -375,6 +408,170 @@ class FirmwareComponent extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showRolloutPanel(name, version, labels) {
|
||||||
|
try {
|
||||||
|
// Get cluster node versions to show which nodes will be affected
|
||||||
|
const nodeVersions = await window.apiClient.getClusterNodeVersions();
|
||||||
|
|
||||||
|
// Filter nodes that match the firmware labels
|
||||||
|
const matchingNodes = nodeVersions.nodes.filter(node => {
|
||||||
|
return this.nodeMatchesLabels(node.labels, labels);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.openRolloutDrawer(name, version, labels, matchingNodes);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get cluster node versions:', error);
|
||||||
|
this.showError('Failed to get cluster information: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeMatchesLabels(nodeLabels, firmwareLabels) {
|
||||||
|
for (const [key, value] of Object.entries(firmwareLabels)) {
|
||||||
|
if (!nodeLabels[key] || nodeLabels[key] !== value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
openRolloutDrawer(name, version, labels, matchingNodes) {
|
||||||
|
this.drawer.openDrawer('Rollout Firmware', (contentContainer, setActiveComponent) => {
|
||||||
|
const rolloutComponent = new RolloutComponent(contentContainer, this.viewModel, this.eventBus);
|
||||||
|
setActiveComponent(rolloutComponent);
|
||||||
|
|
||||||
|
rolloutComponent.setRolloutData(name, version, labels, matchingNodes);
|
||||||
|
rolloutComponent.setOnRolloutCallback((rolloutData) => {
|
||||||
|
this.startRollout(rolloutData);
|
||||||
|
});
|
||||||
|
rolloutComponent.setOnCancelCallback(() => {
|
||||||
|
this.drawer.closeDrawer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store reference for status updates
|
||||||
|
this.currentRolloutComponent = rolloutComponent;
|
||||||
|
|
||||||
|
rolloutComponent.mount();
|
||||||
|
}, null, null, true); // Hide terminal button
|
||||||
|
}
|
||||||
|
|
||||||
|
async startRollout(rolloutData) {
|
||||||
|
try {
|
||||||
|
// Start rollout in the panel (no backdrop)
|
||||||
|
if (this.currentRolloutComponent) {
|
||||||
|
this.currentRolloutComponent.startRollout();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await window.apiClient.startRollout(rolloutData);
|
||||||
|
|
||||||
|
logger.info('Rollout started:', response);
|
||||||
|
this.showSuccess(`Rollout started for ${response.totalNodes} nodes`);
|
||||||
|
|
||||||
|
// Set up WebSocket listener for rollout progress
|
||||||
|
this.setupRolloutProgressListener(response.rolloutId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Rollout failed:', error);
|
||||||
|
this.showError('Rollout failed: ' + error.message);
|
||||||
|
|
||||||
|
// Reset rollout state on error
|
||||||
|
if (this.currentRolloutComponent) {
|
||||||
|
this.currentRolloutComponent.resetRolloutState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showRolloutProgress() {
|
||||||
|
// Create backdrop and progress overlay
|
||||||
|
const backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'rollout-backdrop';
|
||||||
|
backdrop.id = 'rollout-backdrop';
|
||||||
|
|
||||||
|
const progressOverlay = document.createElement('div');
|
||||||
|
progressOverlay.className = 'rollout-progress-overlay';
|
||||||
|
progressOverlay.innerHTML = `
|
||||||
|
<div class="rollout-progress-content">
|
||||||
|
<div class="rollout-progress-header">
|
||||||
|
<h3>Rolling Out Firmware</h3>
|
||||||
|
</div>
|
||||||
|
<div class="rollout-progress-body">
|
||||||
|
<div class="rollout-progress-info">
|
||||||
|
<p>Firmware rollout in progress...</p>
|
||||||
|
<p class="rollout-progress-text">Preparing rollout...</p>
|
||||||
|
</div>
|
||||||
|
<div class="rollout-progress-bar">
|
||||||
|
<div class="rollout-progress-fill" id="rollout-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="rollout-progress-details" id="rollout-progress-details">
|
||||||
|
<div class="rollout-node-list" id="rollout-node-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
backdrop.appendChild(progressOverlay);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
|
// Block UI interactions
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
hideRolloutProgress() {
|
||||||
|
const backdrop = document.getElementById('rollout-backdrop');
|
||||||
|
if (backdrop) {
|
||||||
|
document.body.removeChild(backdrop);
|
||||||
|
}
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRolloutProgressListener(rolloutId) {
|
||||||
|
// Track completed nodes for parallel processing
|
||||||
|
this.completedNodes = new Set();
|
||||||
|
this.totalNodes = 0;
|
||||||
|
|
||||||
|
const progressListener = (data) => {
|
||||||
|
if (data.rolloutId === rolloutId) {
|
||||||
|
// Set total nodes from first update
|
||||||
|
if (this.totalNodes === 0) {
|
||||||
|
this.totalNodes = data.total;
|
||||||
|
}
|
||||||
|
this.updateRolloutProgress(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.wsClient.on('rolloutProgress', progressListener);
|
||||||
|
|
||||||
|
// Store listener for cleanup
|
||||||
|
this.currentRolloutListener = progressListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRolloutProgress(data) {
|
||||||
|
// Update status in the rollout panel
|
||||||
|
if (this.currentRolloutComponent) {
|
||||||
|
this.currentRolloutComponent.updateNodeStatus(data.nodeIp, data.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track completed nodes for parallel processing
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
this.completedNodes.add(data.nodeIp);
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
// Also count failed nodes as "processed" for completion check
|
||||||
|
this.completedNodes.add(data.nodeIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if rollout is complete (all nodes processed)
|
||||||
|
if (this.completedNodes.size >= this.totalNodes) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showSuccess('Rollout completed successfully');
|
||||||
|
|
||||||
|
// Clean up WebSocket listener
|
||||||
|
if (this.currentRolloutListener) {
|
||||||
|
window.wsClient.off('rolloutProgress', this.currentRolloutListener);
|
||||||
|
this.currentRolloutListener = null;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteFirmware(name, version) {
|
async deleteFirmware(name, version) {
|
||||||
try {
|
try {
|
||||||
// Note: The registry API doesn't have a delete endpoint in the OpenAPI spec
|
// Note: The registry API doesn't have a delete endpoint in the OpenAPI spec
|
||||||
|
|||||||
258
public/scripts/components/RolloutComponent.js
Normal file
258
public/scripts/components/RolloutComponent.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -701,10 +701,6 @@ p {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border-primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label-key-input,
|
.label-key-input,
|
||||||
@@ -1954,7 +1950,7 @@ p {
|
|||||||
background: rgba(244, 67, 54, 0.2);
|
background: rgba(244, 67, 54, 0.2);
|
||||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||||
color: #ffcdd2;
|
color: #ffcdd2;
|
||||||
padding: 1rem;
|
/*padding: 1rem;*/
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -2269,7 +2265,6 @@ p {
|
|||||||
.firmware-groups {
|
.firmware-groups {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.firmware-group {
|
.firmware-group {
|
||||||
@@ -2312,11 +2307,27 @@ p {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid var(--border-primary);
|
border-bottom: 1px solid var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.firmware-group-header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.firmware-group-name {
|
.firmware-group-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
@@ -2333,10 +2344,23 @@ p {
|
|||||||
font-weight: 500;
|
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 {
|
.firmware-versions {
|
||||||
display: flex;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
}
|
||||||
|
|
||||||
|
.firmware-group.expanded .firmware-versions {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.firmware-version-item {
|
.firmware-version-item {
|
||||||
@@ -2594,9 +2618,9 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.download-btn:hover {
|
.action-btn.download-btn:hover {
|
||||||
background: rgba(34, 197, 94, 0.1);
|
background: rgba(96, 165, 250, 0.1);
|
||||||
border-color: #22c55e;
|
border-color: var(--accent-secondary);
|
||||||
color: #22c55e;
|
color: var(--accent-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.delete-btn:hover {
|
.action-btn.delete-btn:hover {
|
||||||
@@ -4934,27 +4958,32 @@ select.param-input:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.target-nodes-section h3 {
|
.target-nodes-section h3 {
|
||||||
margin: 0 0 1.5rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-nodes-list {
|
.target-nodes-list {
|
||||||
display: flex;
|
overflow-y: auto;
|
||||||
flex-direction: column;
|
border: 1px solid var(--border-primary);
|
||||||
gap: 0.5rem;
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-node-item {
|
.target-node-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
padding: 0.7rem;
|
background: transparent;
|
||||||
background: var(--bg-primary);
|
border-radius: 0;
|
||||||
border-radius: 12px;
|
gap: 0;
|
||||||
border: 1px solid var(--border-primary);
|
}
|
||||||
gap: 1.5rem;
|
|
||||||
|
.target-node-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-node-item .node-info {
|
.target-node-item .node-info {
|
||||||
@@ -4995,7 +5024,6 @@ select.param-input:focus {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
min-width: 100px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
@@ -6733,7 +6761,7 @@ html {
|
|||||||
.label-chip {
|
.label-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: rgba(30, 58, 138, 0.35);
|
background: rgba(30, 58, 138, 0.35);
|
||||||
@@ -7473,3 +7501,322 @@ html {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 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-secondary: rgba(255, 255, 255, 0.15);
|
||||||
--border-hover: rgba(255, 255, 255, 0.2);
|
--border-hover: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
--accent-primary: #4ade80;
|
--accent-primary: #1d8b45;
|
||||||
--accent-secondary: #60a5fa;
|
--accent-secondary: #60a5fa;
|
||||||
--accent-warning: #fbbf24;
|
--accent-warning: #fbbf24;
|
||||||
--accent-error: #f87171;
|
--accent-error: #f87171;
|
||||||
|
|||||||
Reference in New Issue
Block a user