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)} + + + ${rows} +
    ${t('monitoring.thName')}${t('monitoring.thStatus')}
    +
    `; + 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",