feat(updates): visual update indicators, progress feedback, settings pull
- Dashboard: update badge (orange) injected lazily into customer Status cell after table renders via GET /monitoring/customers/local-update-status (local-only Docker inspect, no Hub call on every page load) - Customer detail Deployment tab: "Update Images" button with spinner, shows success/error inline without page reload - Monitoring Update All: now synchronous + sequential (one customer at a time), shows live spinner + per-customer results table on completion - Settings > Docker Images: "Pull from Docker Hub" button with spinner and inline status message - /monitoring/customers/local-update-status: new lightweight endpoint (no network, pure local Docker inspect) - /monitoring/customers/update-all: removed BackgroundTasks, now awaits each customer sequentially and returns detailed per-customer results Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -634,6 +634,13 @@
|
||||
Settings</span></button>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<h6 data-i18n="settings.pullImagesTitle">Pull Latest Images from Docker Hub</h6>
|
||||
<p class="text-muted small" data-i18n="settings.pullImagesHint">Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.</p>
|
||||
<button class="btn btn-outline-primary" onclick="pullAllImagesSettings()" id="btn-pull-images-settings">
|
||||
<i class="bi bi-cloud-download me-1"></i><span data-i18n="settings.pullImages">Pull from Docker Hub</span>
|
||||
</button>
|
||||
<span id="pull-images-settings-status" class="ms-2 text-muted small"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
114
static/js/app.js
114
static/js/app.js
@@ -485,11 +485,11 @@ function renderCustomersTable(data) {
|
||||
const dashLink = dPort
|
||||
? `<a href="${esc(dashUrl || 'http://localhost:' + dPort)}" target="_blank" class="text-decoration-none" title="${t('customer.openDashboard')}">:${dPort} <i class="bi bi-box-arrow-up-right"></i></a>`
|
||||
: '-';
|
||||
return `<tr>
|
||||
return `<tr data-customer-id="${c.id}">
|
||||
<td>${c.id}</td>
|
||||
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
|
||||
<td><code>${esc(c.subdomain)}</code></td>
|
||||
<td>${statusBadge(c.status)}</td>
|
||||
<td><span class="customer-status-cell">${statusBadge(c.status)}</span></td>
|
||||
<td>${dashLink}</td>
|
||||
<td>${c.max_devices}</td>
|
||||
<td>${formatDate(c.created_at)}</td>
|
||||
@@ -517,6 +517,26 @@ function renderCustomersTable(data) {
|
||||
paginationHtml += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
|
||||
}
|
||||
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 = '<i class="bi bi-arrow-repeat"></i> Update';
|
||||
cell.appendChild(badge);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
@@ -742,8 +762,13 @@ async function viewCustomer(id) {
|
||||
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
|
||||
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
|
||||
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
|
||||
<button class="btn btn-outline-primary btn-sm me-1" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
|
||||
<button class="btn btn-outline-warning btn-sm" id="btn-update-images-detail" onclick="updateCustomerImagesFromDetail(${id})">
|
||||
<span id="update-detail-spinner" class="spinner-border spinner-border-sm d-none me-1"></span>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>${t('customer.updateImages')}
|
||||
</button>
|
||||
</div>
|
||||
<div id="detail-update-result"></div>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('detail-deployment-content').innerHTML = `
|
||||
@@ -1667,7 +1692,7 @@ async function checkImageUpdates() {
|
||||
? `<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})"
|
||||
? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})"
|
||||
title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>`
|
||||
: '';
|
||||
return `<tr>
|
||||
@@ -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 = `<div class="alert alert-info py-2 mt-2"><span class="spinner-border spinner-border-sm me-2"></span>${t('customer.updateInProgress')}</div>`;
|
||||
try {
|
||||
const data = await api('POST', `/customers/${id}/update-images`);
|
||||
resultDiv.innerHTML = `<div class="alert alert-success py-2 mt-2"><i class="bi bi-check-circle me-1"></i>${esc(data.message)}</div>`;
|
||||
setTimeout(() => { resultDiv.innerHTML = ''; }, 6000);
|
||||
} catch (err) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger py-2 mt-2"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</div>`;
|
||||
} 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 = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
}
|
||||
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 = '<i class="bi bi-arrow-repeat"></i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.updating')}`;
|
||||
|
||||
const progressDiv = document.createElement('div');
|
||||
progressDiv.className = 'alert alert-info mt-3';
|
||||
progressDiv.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${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 => `<tr>
|
||||
<td>${esc(r.customer_name)}</td>
|
||||
<td>${r.success
|
||||
? '<span class="badge bg-success"><i class="bi bi-check-lg"></i> OK</span>'
|
||||
: '<span class="badge bg-danger"><i class="bi bi-x-lg"></i> Error</span>'}</td>
|
||||
<td class="small text-muted">${esc(r.error || '')}</td>
|
||||
</tr>`).join('');
|
||||
const resultHtml = `<div class="alert alert-${allOk ? 'success' : 'warning'} mt-3">
|
||||
<strong>${esc(data.message)}</strong>
|
||||
<table class="table table-sm mb-0 mt-2">
|
||||
<thead><tr><th>${t('monitoring.thName')}</th><th>${t('monitoring.thStatus')}</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
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 = `<i class="bi bi-lightning-charge-fill me-1"></i>${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 = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.pulling')}`;
|
||||
try {
|
||||
await api('POST', '/monitoring/images/pull');
|
||||
statusEl.innerHTML = `<i class="bi bi-check-circle text-success me-1"></i>${t('monitoring.pullStartedShort')}`;
|
||||
setTimeout(() => { statusEl.innerHTML = ''; }, 8000);
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</span>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user