diff --git a/app/routers/monitoring.py b/app/routers/monitoring.py
index 0a0a5be..211190a 100644
--- a/app/routers/monitoring.py
+++ b/app/routers/monitoring.py
@@ -196,16 +196,37 @@ async def pull_all_netbird_images(
return {"message": "Image pull started in background.", "images": images}
+@router.get("/customers/local-update-status")
+async def customers_local_update_status(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> list[dict[str, Any]]:
+ """Fast local-only check for outdated customer containers.
+
+ Compares running container image IDs against locally stored images.
+ No network call — safe to call on every dashboard load.
+ """
+ config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
+ if not config:
+ return []
+ deployments = db.query(Deployment).all()
+ results = []
+ for dep in deployments:
+ cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
+ results.append({"customer_id": dep.customer_id, "needs_update": cs["needs_update"]})
+ return results
+
+
@router.post("/customers/update-all")
async def update_all_customers(
- background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- """Recreate containers for all customers that have outdated images.
+ """Recreate containers for all customers with outdated images — sequential, synchronous.
- Only customers where at least one container runs an outdated image are updated.
+ Updates customers one at a time so a failing customer does not block others.
Images must already be pulled. Data is preserved (bind mounts).
+ Returns detailed per-customer results.
"""
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
@@ -214,38 +235,40 @@ async def update_all_customers(
if not config:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
- # Collect customers that need updating
deployments = db.query(Deployment).all()
to_update = []
for dep in deployments:
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
if cs["needs_update"]:
customer = dep.customer
- instance_dir = str(dep.container_prefix).replace(
- "netbird-", "", 1
- ) # subdomain
to_update.append({
"instance_dir": f"{config.data_dir}/{customer.subdomain}",
"project_name": dep.container_prefix,
"customer_name": customer.name,
+ "customer_id": customer.id,
})
if not to_update:
- return {"message": "All customers are already up to date.", "updated": 0}
+ return {"message": "All customers are already up to date.", "updated": 0, "results": []}
- async def _update_all_bg() -> None:
- for entry in to_update:
- try:
- await image_service.update_customer_containers(
- entry["instance_dir"], entry["project_name"]
- )
- logger.info("Updated containers for %s", entry["project_name"])
- except Exception:
- logger.exception("Failed to update %s", entry["project_name"])
+ # Update customers sequentially — one at a time
+ update_results = []
+ for entry in to_update:
+ res = await image_service.update_customer_containers(
+ entry["instance_dir"], entry["project_name"]
+ )
+ ok = res["success"]
+ logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else res.get("error"))
+ update_results.append({
+ "customer_name": entry["customer_name"],
+ "customer_id": entry["customer_id"],
+ "success": ok,
+ "error": res.get("error"),
+ })
- background_tasks.add_task(_update_all_bg)
- names = [e["customer_name"] for e in to_update]
+ success_count = sum(1 for r in update_results if r["success"])
return {
- "message": f"Updating {len(to_update)} customer(s) in background.",
- "customers": names,
+ "message": f"Updated {success_count} of {len(update_results)} customer(s).",
+ "updated": success_count,
+ "results": update_results,
}
diff --git a/static/index.html b/static/index.html
index 0d20b49..4c236c7 100644
--- a/static/index.html
+++ b/static/index.html
@@ -634,6 +634,13 @@
Settings
+
+ Pull Latest Images from Docker Hub
+ Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.
+
+
diff --git a/static/js/app.js b/static/js/app.js
index 50a0249..e19de23 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -485,11 +485,11 @@ function renderCustomersTable(data) {
const dashLink = dPort
? `:${dPort} `
: '-';
- return `
+ return `
| ${c.id} |
${esc(c.name)} |
${esc(c.subdomain)} |
- ${statusBadge(c.status)} |
+ ${statusBadge(c.status)} |
${dashLink} |
${c.max_devices} |
${formatDate(c.created_at)} |
@@ -517,6 +517,26 @@ function renderCustomersTable(data) {
paginationHtml += `${i}`;
}
document.getElementById('pagination-controls').innerHTML = paginationHtml;
+
+ // Lazy-load update badges after table renders (best-effort, silent fail)
+ loadCustomerUpdateBadges().catch(() => {});
+}
+
+async function loadCustomerUpdateBadges() {
+ const data = await api('GET', '/monitoring/customers/local-update-status');
+ data.forEach(s => {
+ if (!s.needs_update) return;
+ const tr = document.querySelector(`tr[data-customer-id="${s.customer_id}"]`);
+ if (!tr) return;
+ const cell = tr.querySelector('.customer-status-cell');
+ if (cell && !cell.querySelector('.update-badge')) {
+ const badge = document.createElement('span');
+ badge.className = 'badge bg-warning text-dark update-badge ms-1';
+ badge.title = t('monitoring.updateAvailable');
+ badge.innerHTML = ' Update';
+ cell.appendChild(badge);
+ }
+ });
}
function goToPage(page) {
@@ -742,8 +762,13 @@ async function viewCustomer(id) {
-
+
+
+
`;
} else {
document.getElementById('detail-deployment-content').innerHTML = `
@@ -1667,7 +1692,7 @@ async function checkImageUpdates() {
? `${t('monitoring.needsUpdate')}`
: `${t('monitoring.upToDate')}`;
const updateBtn = c.needs_update
- ? ``
: '';
return `
@@ -1736,26 +1761,103 @@ async function pullAllImages() {
}
}
+async function updateCustomerImagesFromDetail(id) {
+ const btn = document.getElementById('btn-update-images-detail');
+ const spinner = document.getElementById('update-detail-spinner');
+ const resultDiv = document.getElementById('detail-update-result');
+ btn.disabled = true;
+ spinner.classList.remove('d-none');
+ resultDiv.innerHTML = `${t('customer.updateInProgress')}
`;
+ try {
+ const data = await api('POST', `/customers/${id}/update-images`);
+ resultDiv.innerHTML = `${esc(data.message)}
`;
+ setTimeout(() => { resultDiv.innerHTML = ''; }, 6000);
+ } catch (err) {
+ resultDiv.innerHTML = `${esc(err.message)}
`;
+ } finally {
+ btn.disabled = false;
+ spinner.classList.add('d-none');
+ }
+}
+
async function updateCustomerImages(customerId) {
+ // Find the update button for this customer row and show a spinner
+ const btn = document.querySelector(`tr[data-customer-id="${customerId}"] .btn-update-customer`);
+ if (btn) {
+ btn.disabled = true;
+ btn.innerHTML = '';
+ }
try {
await api('POST', `/customers/${customerId}/update-images`);
showToast(t('monitoring.updateDone'));
setTimeout(() => checkImageUpdates(), 2000);
} catch (err) {
showMonitoringAlert('danger', err.message);
+ if (btn) {
+ btn.disabled = false;
+ btn.innerHTML = '';
+ }
}
}
async function updateAllCustomers() {
if (!confirm(t('monitoring.confirmUpdateAll'))) return;
const btn = document.getElementById('btn-update-all');
+ const body = document.getElementById('image-updates-body');
btn.disabled = true;
+ btn.innerHTML = `${t('monitoring.updating')}`;
+
+ const progressDiv = document.createElement('div');
+ progressDiv.className = 'alert alert-info mt-3';
+ progressDiv.innerHTML = `${t('monitoring.updateAllProgress')}`;
+ body.appendChild(progressDiv);
+
try {
const data = await api('POST', '/monitoring/customers/update-all');
- showToast(data.message || t('monitoring.updateAllStarted'));
- setTimeout(() => checkImageUpdates(), 5000);
+ progressDiv.remove();
+
+ if (data.results && data.results.length > 0) {
+ const allOk = data.updated === data.results.length;
+ const rows = data.results.map(r => `
+ | ${esc(r.customer_name)} |
+ ${r.success
+ ? ' OK'
+ : ' Error'} |
+ ${esc(r.error || '')} |
+
`).join('');
+ const resultHtml = `
+
${esc(data.message)}
+
+ | ${t('monitoring.thName')} | ${t('monitoring.thStatus')} | |
+ ${rows}
+
+
`;
+ body.insertAdjacentHTML('beforeend', resultHtml);
+ } else {
+ showToast(data.message);
+ }
+ setTimeout(() => checkImageUpdates(), 2000);
} catch (err) {
+ progressDiv.remove();
showMonitoringAlert('danger', err.message);
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = `${t('monitoring.updateAll')}`;
+ }
+}
+
+async function pullAllImagesSettings() {
+ if (!confirm(t('monitoring.confirmPull'))) return;
+ const btn = document.getElementById('btn-pull-images-settings');
+ const statusEl = document.getElementById('pull-images-settings-status');
+ btn.disabled = true;
+ statusEl.innerHTML = `${t('monitoring.pulling')}`;
+ try {
+ await api('POST', '/monitoring/images/pull');
+ statusEl.innerHTML = `${t('monitoring.pullStartedShort')}`;
+ setTimeout(() => { statusEl.innerHTML = ''; }, 8000);
+ } catch (err) {
+ statusEl.innerHTML = `${esc(err.message)}`;
} finally {
btn.disabled = false;
}
diff --git a/static/lang/de.json b/static/lang/de.json
index 65995ff..71fe9af 100644
--- a/static/lang/de.json
+++ b/static/lang/de.json
@@ -89,7 +89,9 @@
"thHealth": "Zustand",
"thImage": "Image",
"lastCheck": "Letzte Prüfung: {time}",
- "openDashboard": "Dashboard öffnen"
+ "openDashboard": "Dashboard öffnen",
+ "updateImages": "Images aktualisieren",
+ "updateInProgress": "Container werden aktualisiert — bitte warten…"
},
"settings": {
"title": "Systemeinstellungen",
@@ -152,6 +154,9 @@
"dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Image-Einstellungen speichern",
+ "pullImagesTitle": "Neueste Images von Docker Hub laden",
+ "pullImagesHint": "Lädt die neuesten Versionen aller konfigurierten NetBird Images. Danach können Kunden-Container über das Monitoring aktualisiert werden.",
+ "pullImages": "Von Docker Hub laden",
"brandingTitle": "Branding-Einstellungen",
"companyName": "Firmen- / Anwendungsname",
"companyNamePlaceholder": "NetBird MSP Appliance",
@@ -392,6 +397,10 @@
"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."
+ "updateDone": "Kunden-Container aktualisiert.",
+ "updating": "Wird aktualisiert…",
+ "updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…",
+ "pulling": "Wird geladen…",
+ "pullStartedShort": "Download im Hintergrund gestartet."
}
}
\ No newline at end of file
diff --git a/static/lang/en.json b/static/lang/en.json
index a4d4fce..d60c777 100644
--- a/static/lang/en.json
+++ b/static/lang/en.json
@@ -89,7 +89,9 @@
"thHealth": "Health",
"thImage": "Image",
"lastCheck": "Last check: {time}",
- "openDashboard": "Open Dashboard"
+ "openDashboard": "Open Dashboard",
+ "updateImages": "Update Images",
+ "updateInProgress": "Updating containers — please wait…"
},
"customerModal": {
"newCustomer": "New Customer",
@@ -173,6 +175,9 @@
"dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Save Image Settings",
+ "pullImagesTitle": "Pull Latest Images from Docker Hub",
+ "pullImagesHint": "Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.",
+ "pullImages": "Pull from Docker Hub",
"brandingTitle": "Branding Settings",
"companyName": "Company / Application Name",
"companyNamePlaceholder": "NetBird MSP Appliance",
@@ -299,7 +304,11 @@
"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."
+ "updateDone": "Customer containers updated.",
+ "updating": "Updating…",
+ "updateAllProgress": "Updating customer containers one by one — please wait…",
+ "pulling": "Pulling…",
+ "pullStartedShort": "Pull started in background."
},
"userModal": {
"title": "New User",