feat(updates): NetBird container image update management
- New image_service.py: Docker Hub digest check (no pull), local digest/ID
comparison, pull_all_images, per-customer container image status, and
update_customer_containers (docker compose up -d, data-safe)
- Monitoring endpoints: GET /images/check (hub vs local + per-customer
needs_update), POST /images/pull (background), POST /customers/update-all
- Deployment endpoint: POST /{id}/update-images (single-customer update)
- Monitoring page: "NetBird Container Updates" card with Check / Pull / Update
All buttons; image status table and per-customer update table with inline
update buttons
- i18n: added keys in en.json and de.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1152,7 +1152,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Customer Statuses -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header" data-i18n="monitoring.allCustomerDeployments">All Customer Deployments
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
@@ -1178,6 +1178,27 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NetBird Container Updates -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-arrow-repeat me-2"></i><span data-i18n="monitoring.imageUpdates">NetBird Container Updates</span></span>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="checkImageUpdates()" id="btn-check-updates">
|
||||
<i class="bi bi-search me-1"></i><span data-i18n="monitoring.checkUpdates">Check for Updates</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="pullAllImages()" id="btn-pull-images">
|
||||
<i class="bi bi-cloud-download me-1"></i><span data-i18n="monitoring.pullImages">Pull Latest Images</span>
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm d-none" onclick="updateAllCustomers()" id="btn-update-all">
|
||||
<i class="bi bi-lightning-charge-fill me-1"></i><span data-i18n="monitoring.updateAll">Update All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" id="image-updates-body">
|
||||
<p class="text-muted mb-0" data-i18n="monitoring.clickCheckUpdates">Click "Check for Updates" to compare local images with Docker Hub.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
138
static/js/app.js
138
static/js/app.js
@@ -1633,6 +1633,144 @@ async function loadAllCustomerStatuses() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image Updates
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkImageUpdates() {
|
||||
const btn = document.getElementById('btn-check-updates');
|
||||
const body = document.getElementById('image-updates-body');
|
||||
btn.disabled = true;
|
||||
body.innerHTML = `<div class="text-muted"><span class="spinner-border spinner-border-sm me-2"></span>${t('common.loading')}</div>`;
|
||||
|
||||
try {
|
||||
const data = await api('GET', '/monitoring/images/check');
|
||||
|
||||
// Image status table
|
||||
const imageRows = Object.values(data.images).map(img => {
|
||||
const badge = img.update_available
|
||||
? `<span class="badge bg-warning text-dark">${t('monitoring.updateAvailable')}</span>`
|
||||
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
|
||||
const shortDigest = d => d ? d.substring(7, 19) + '…' : '-';
|
||||
return `<tr>
|
||||
<td><code class="small">${esc(img.image)}</code></td>
|
||||
<td class="small text-muted">${shortDigest(img.local_digest)}</td>
|
||||
<td class="small text-muted">${shortDigest(img.hub_digest)}</td>
|
||||
<td>${badge}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Customer status table
|
||||
const customerRows = data.customer_status.length === 0
|
||||
? `<tr><td colspan="3" class="text-center text-muted py-3">${t('monitoring.noCustomers')}</td></tr>`
|
||||
: data.customer_status.map(c => {
|
||||
const badge = c.needs_update
|
||||
? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>`
|
||||
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
|
||||
const updateBtn = c.needs_update
|
||||
? `<button class="btn btn-sm btn-outline-warning ms-2" onclick="updateCustomerImages(${c.customer_id})"
|
||||
title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>`
|
||||
: '';
|
||||
return `<tr>
|
||||
<td>${c.customer_id}</td>
|
||||
<td>${esc(c.customer_name)} <code class="small text-muted">${esc(c.subdomain)}</code></td>
|
||||
<td>${badge}${updateBtn}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Show "Update All" button if any customer needs update
|
||||
const updateAllBtn = document.getElementById('btn-update-all');
|
||||
if (data.customer_status.some(c => c.needs_update)) {
|
||||
updateAllBtn.classList.remove('d-none');
|
||||
} else {
|
||||
updateAllBtn.classList.add('d-none');
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<h6 class="mb-2">${t('monitoring.imageStatusTitle')}</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>${t('monitoring.thImage')}</th>
|
||||
<th>${t('monitoring.thLocalDigest')}</th>
|
||||
<th>${t('monitoring.thHubDigest')}</th>
|
||||
<th>${t('monitoring.thStatus')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${imageRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h6 class="mb-2">${t('monitoring.customerImageTitle')}</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>${t('monitoring.thId')}</th>
|
||||
<th>${t('monitoring.thName')}</th>
|
||||
<th>${t('monitoring.thStatus')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${customerRows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
} catch (err) {
|
||||
body.innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pullAllImages() {
|
||||
if (!confirm(t('monitoring.confirmPull'))) return;
|
||||
const btn = document.getElementById('btn-pull-images');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api('POST', '/monitoring/images/pull');
|
||||
showToast(t('monitoring.pullStarted'));
|
||||
// Re-check after a few seconds to let pull finish
|
||||
setTimeout(() => checkImageUpdates(), 5000);
|
||||
} catch (err) {
|
||||
showMonitoringAlert('danger', err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCustomerImages(customerId) {
|
||||
try {
|
||||
await api('POST', `/customers/${customerId}/update-images`);
|
||||
showToast(t('monitoring.updateDone'));
|
||||
setTimeout(() => checkImageUpdates(), 2000);
|
||||
} catch (err) {
|
||||
showMonitoringAlert('danger', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAllCustomers() {
|
||||
if (!confirm(t('monitoring.confirmUpdateAll'))) return;
|
||||
const btn = document.getElementById('btn-update-all');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const data = await api('POST', '/monitoring/customers/update-all');
|
||||
showToast(data.message || t('monitoring.updateAllStarted'));
|
||||
setTimeout(() => checkImageUpdates(), 5000);
|
||||
} catch (err) {
|
||||
showMonitoringAlert('danger', err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showMonitoringAlert(type, msg) {
|
||||
const body = document.getElementById('image-updates-body');
|
||||
const existing = body.querySelector('.alert');
|
||||
if (existing) existing.remove();
|
||||
const div = document.createElement('div');
|
||||
div.className = `alert alert-${type} mt-2`;
|
||||
div.textContent = msg;
|
||||
body.prepend(div);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -373,6 +373,25 @@
|
||||
"thDashboard": "Dashboard",
|
||||
"thRelayPort": "Relay-Port",
|
||||
"thContainers": "Container",
|
||||
"noCustomers": "Keine Kunden."
|
||||
"noCustomers": "Keine Kunden.",
|
||||
"imageUpdates": "NetBird Container Updates",
|
||||
"checkUpdates": "Auf Updates prüfen",
|
||||
"pullImages": "Neueste Images laden",
|
||||
"updateAll": "Alle aktualisieren",
|
||||
"clickCheckUpdates": "Klicken Sie auf \"Auf Updates prüfen\" um lokale Images mit Docker Hub zu vergleichen.",
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"upToDate": "Aktuell",
|
||||
"needsUpdate": "Update erforderlich",
|
||||
"updateCustomer": "Diesen Kunden aktualisieren",
|
||||
"imageStatusTitle": "Image-Status (vs. Docker Hub)",
|
||||
"customerImageTitle": "Kunden-Container Status",
|
||||
"thImage": "Image",
|
||||
"thLocalDigest": "Lokaler Digest",
|
||||
"thHubDigest": "Hub Digest",
|
||||
"confirmPull": "Neueste NetBird Images von Docker Hub laden? Dies kann einige Minuten dauern.",
|
||||
"pullStarted": "Image-Download im Hintergrund gestartet. Prüfung in 5 Sekunden…",
|
||||
"confirmUpdateAll": "Container aller Kunden mit veralteten Images neu erstellen? Laufende Dienste werden kurz neu gestartet.",
|
||||
"updateAllStarted": "Aktualisierung im Hintergrund gestartet.",
|
||||
"updateDone": "Kunden-Container aktualisiert."
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,26 @@
|
||||
"thDashboard": "Dashboard",
|
||||
"thRelayPort": "Relay Port",
|
||||
"thContainers": "Containers",
|
||||
"noCustomers": "No customers."
|
||||
"noCustomers": "No customers.",
|
||||
"imageUpdates": "NetBird Container Updates",
|
||||
"checkUpdates": "Check for Updates",
|
||||
"pullImages": "Pull Latest Images",
|
||||
"updateAll": "Update All",
|
||||
"clickCheckUpdates": "Click \"Check for Updates\" to compare local images with Docker Hub.",
|
||||
"updateAvailable": "Update available",
|
||||
"upToDate": "Up to date",
|
||||
"needsUpdate": "Needs update",
|
||||
"updateCustomer": "Update this customer",
|
||||
"imageStatusTitle": "Image Status (vs. Docker Hub)",
|
||||
"customerImageTitle": "Customer Container Status",
|
||||
"thImage": "Image",
|
||||
"thLocalDigest": "Local Digest",
|
||||
"thHubDigest": "Hub Digest",
|
||||
"confirmPull": "Pull the latest NetBird images from Docker Hub? This may take a few minutes.",
|
||||
"pullStarted": "Image pull started in background. Re-checking in 5 seconds…",
|
||||
"confirmUpdateAll": "Recreate containers for all customers that have outdated images? Running services will briefly restart.",
|
||||
"updateAllStarted": "Update started in background.",
|
||||
"updateDone": "Customer containers updated."
|
||||
},
|
||||
"userModal": {
|
||||
"title": "New User",
|
||||
|
||||
Reference in New Issue
Block a user