From 2d9620c6d1be865a1443465400c761082e1fbf4d Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sat, 16 May 2026 20:31:02 +0200 Subject: [PATCH] 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. --- bincio/serve/deps.py | 2 + bincio/serve/routers/admin.py | 68 ++++++++++++++++++++ site/src/pages/admin/index.astro | 106 +++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/bincio/serve/deps.py b/bincio/serve/deps.py index d0beab3..f648db9 100644 --- a/bincio/serve/deps.py +++ b/bincio/serve/deps.py @@ -43,6 +43,8 @@ sync_secret: str = "" _db = None _strava_sync_running = False _strava_sync_lock = threading.Lock() +_garmin_sync_running = False +_garmin_sync_lock = threading.Lock() # ── Constants ───────────────────────────────────────────────────────────────── diff --git a/bincio/serve/routers/admin.py b/bincio/serve/routers/admin.py index 25687bc..4b294a0 100644 --- a/bincio/serve/routers/admin.py +++ b/bincio/serve/routers/admin.py @@ -585,6 +585,74 @@ async def admin_strava_sync_run( 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") async def admin_recompute_elevation( handle: str, diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 1cd14e7..4d8c45d 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -57,6 +57,29 @@ import Base from '../../layouts/Base.astro'; +
+
+

Garmin Sync

+ +
+
+ + + + + + + + + + + + +
HandleImported (last run)Last runStatus
Loading…
+
+ +
+
@@ -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 | null = null; + + function garminStatusBadge(status: string | null, errMsg: string | null): string { + if (!status) return 'never run'; + const map: Record = { + 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 + ? `${errMsg.slice(0, 80)}` + : ''; + return `${label}${detail}`; + } + + async function loadGarminSync() { + try { + const r = await fetch('/api/admin/garmin-sync', { credentials: 'include' }); + if (!r.ok) { + garminTbody.innerHTML = `Error ${r.status}`; + 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 = 'No users with Garmin connected.'; + return; + } + + garminTbody.innerHTML = users.map((u: any) => ` + + @${u.handle} + ${u.run_imported ?? '—'} + ${fmtDate(u.last_run)} + ${garminStatusBadge(u.run_status, u.run_error_message)} + `).join(''); + } catch (err) { + garminTbody.innerHTML = `${String(err)}`; + } + } + + 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(); loadSync(); + loadGarminSync();