Admin: add Garmin sync status panel

New /api/admin/garmin-sync (GET) and /api/admin/garmin-sync/run (POST)
endpoints mirror the Strava equivalents, reading _garmin_sync_status.json
per user and exposing a run-now button. Admin page shows the Garmin table
below the Strava one, with auth_error/api_error/ok badges and live polling
while a sync is running.
This commit is contained in:
Davide Scaini
2026-05-16 20:31:02 +02:00
parent 2c69e75842
commit 2d9620c6d1
3 changed files with 176 additions and 0 deletions
+2
View File
@@ -43,6 +43,8 @@ sync_secret: str = ""
_db = None _db = None
_strava_sync_running = False _strava_sync_running = False
_strava_sync_lock = threading.Lock() _strava_sync_lock = threading.Lock()
_garmin_sync_running = False
_garmin_sync_lock = threading.Lock()
# ── Constants ───────────────────────────────────────────────────────────────── # ── Constants ─────────────────────────────────────────────────────────────────
+68
View File
@@ -585,6 +585,74 @@ async def admin_strava_sync_run(
return JSONResponse({"ok": True}, status_code=202) return JSONResponse({"ok": True}, status_code=202)
@router.get("/api/admin/garmin-sync")
async def admin_garmin_sync_status(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Return per-user Garmin sync status for the admin panel."""
deps._require_admin(bincio_session)
root = deps._get_data_dir()
users = []
for cf in sorted(root.glob("*/garmin_creds.json")):
user_dir = cf.parent
handle = user_dir.name
run_status: str | None = None
run_imported = 0
run_errors = 0
run_error_message: str | None = None
last_run: str | None = None
status_path = user_dir / "_garmin_sync_status.json"
if status_path.exists():
try:
ss = json.loads(status_path.read_text(encoding="utf-8"))
run_status = ss.get("status")
run_imported = ss.get("imported", 0)
run_errors = ss.get("errors", 0)
run_error_message = ss.get("error_message")
last_run = ss.get("last_run")
except (OSError, json.JSONDecodeError):
pass
users.append({
"handle": handle,
"run_status": run_status,
"run_imported": run_imported,
"run_errors": run_errors,
"run_error_message": run_error_message,
"last_run": last_run,
})
return JSONResponse({"running": deps._garmin_sync_running, "users": users})
@router.post("/api/admin/garmin-sync/run")
async def admin_garmin_sync_run(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Trigger an immediate Garmin sync for all users (admin only)."""
deps._require_admin(bincio_session)
with deps._garmin_sync_lock:
if deps._garmin_sync_running:
raise HTTPException(409, "Sync already running")
deps._garmin_sync_running = True
def _run() -> None:
try:
from bincio.sync_garmin import sync_all
results = sync_all(deps._get_data_dir())
total_new = sum(n for n, _ in results.values())
if total_new > 0:
tasks._site_rebuild_event.set()
except Exception:
log.exception("admin_garmin_sync_run: unexpected error")
finally:
deps._garmin_sync_running = False
threading.Thread(target=_run, daemon=True, name="admin-garmin-sync").start()
return JSONResponse({"ok": True}, status_code=202)
@router.post("/api/admin/users/{handle}/recompute-elevation") @router.post("/api/admin/users/{handle}/recompute-elevation")
async def admin_recompute_elevation( async def admin_recompute_elevation(
handle: str, handle: str,
+106
View File
@@ -57,6 +57,29 @@ import Base from '../../layouts/Base.astro';
<p id="strava-sync-running-note" class="text-xs text-blue-400 mt-2 hidden">Sync running… refreshing every 3 s</p> <p id="strava-sync-running-note" class="text-xs text-blue-400 mt-2 hidden">Sync running… refreshing every 3 s</p>
</div> </div>
<div class="mt-10">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider">Garmin Sync</h2>
<button id="garmin-run-btn" class="text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-blue-900 hover:text-blue-300 text-zinc-400 transition-colors">Run sync now</button>
</div>
<div class="overflow-x-auto rounded-lg border border-zinc-800">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-zinc-500 text-xs border-b border-zinc-800">
<th class="px-4 py-2 font-medium">Handle</th>
<th class="px-4 py-2 font-medium text-right">Imported (last run)</th>
<th class="px-4 py-2 font-medium">Last run</th>
<th class="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody id="garmin-sync-list">
<tr><td colspan="4" class="px-4 py-6 text-zinc-500 text-center">Loading…</td></tr>
</tbody>
</table>
</div>
<p id="garmin-sync-running-note" class="text-xs text-blue-400 mt-2 hidden">Sync running… refreshing every 3 s</p>
</div>
<!-- Re-extract progress modal --> <!-- Re-extract progress modal -->
<dialog id="reextract-dialog" class="rounded-xl bg-zinc-900 border border-zinc-700 p-6 text-white max-w-2xl w-full backdrop:bg-black/60"> <dialog id="reextract-dialog" class="rounded-xl bg-zinc-900 border border-zinc-700 p-6 text-white max-w-2xl w-full backdrop:bg-black/60">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -613,6 +636,89 @@ import Base from '../../layouts/Base.astro';
} }
}); });
// ── Garmin sync ──────────────────────────────────────────────────────────────
const garminTbody = document.getElementById('garmin-sync-list')!;
const garminRunBtn = document.getElementById('garmin-run-btn') as HTMLButtonElement;
const garminRunNote = document.getElementById('garmin-sync-running-note')!;
let garminPollTimer: ReturnType<typeof setInterval> | null = null;
function garminStatusBadge(status: string | null, errMsg: string | null): string {
if (!status) return '<span class="text-xs text-zinc-500">never run</span>';
const map: Record<string, [string, string]> = {
ok: ['text-green-400', 'OK'],
no_credentials: ['text-amber-400', 'No credentials'],
auth_error: ['text-red-400', 'Auth error'],
api_error: ['text-red-400', 'API error'],
};
const [color, label] = map[status] ?? ['text-zinc-400', status];
const detail = errMsg
? `<span class="block text-zinc-500 text-xs truncate max-w-xs" title="${errMsg.replace(/"/g, '&quot;')}">${errMsg.slice(0, 80)}</span>`
: '';
return `<span class="${color} font-medium text-xs">${label}</span>${detail}`;
}
async function loadGarminSync() {
try {
const r = await fetch('/api/admin/garmin-sync', { credentials: 'include' });
if (!r.ok) {
garminTbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-red-400 text-center">Error ${r.status}</td></tr>`;
return;
}
const { running, users } = await r.json();
if (running) {
garminRunBtn.disabled = true;
garminRunBtn.textContent = 'Running…';
garminRunNote.classList.remove('hidden');
if (!garminPollTimer) {
garminPollTimer = setInterval(loadGarminSync, 3000);
}
} else {
garminRunBtn.disabled = false;
garminRunBtn.textContent = 'Run sync now';
garminRunNote.classList.add('hidden');
if (garminPollTimer) { clearInterval(garminPollTimer); garminPollTimer = null; }
}
if (!users.length) {
garminTbody.innerHTML = '<tr><td colspan="4" class="px-4 py-6 text-zinc-500 text-center">No users with Garmin connected.</td></tr>';
return;
}
garminTbody.innerHTML = users.map((u: any) => `
<tr class="border-b border-zinc-800/50">
<td class="px-4 py-3 text-white font-mono text-xs">@${u.handle}</td>
<td class="px-4 py-3 text-right text-zinc-300 tabular-nums">${u.run_imported ?? '—'}</td>
<td class="px-4 py-3 text-zinc-400 text-xs">${fmtDate(u.last_run)}</td>
<td class="px-4 py-3">${garminStatusBadge(u.run_status, u.run_error_message)}</td>
</tr>`).join('');
} catch (err) {
garminTbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-red-400 text-center">${String(err)}</td></tr>`;
}
}
garminRunBtn.addEventListener('click', async () => {
garminRunBtn.disabled = true;
garminRunBtn.textContent = 'Starting…';
try {
const r = await fetch('/api/admin/garmin-sync/run', { method: 'POST', credentials: 'include' });
if (r.ok) {
await loadGarminSync();
} else {
const d = await r.json().catch(() => ({}));
garminRunBtn.disabled = false;
garminRunBtn.textContent = 'Error: ' + (d.detail ?? r.status);
garminRunBtn.classList.add('text-red-400');
}
} catch {
garminRunBtn.disabled = false;
garminRunBtn.textContent = 'Error';
garminRunBtn.classList.add('text-red-400');
}
});
load(); load();
loadSync(); loadSync();
loadGarminSync();
</script> </script>