From 2287d6e2ee40eaaadc995ef4598605826b6bbe87 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 8 May 2026 13:44:23 +0200 Subject: [PATCH] Add Strava sync status report and manual trigger to admin panel Each sync run now writes _strava_sync_status.json per user (status, imported count, error message). New admin endpoints expose this data and allow triggering an on-demand sync. The admin page gains a Strava Sync section showing per-user token/credentials state, total imported, last sync time, and last-run status with inline error messages. --- bincio/serve/server.py | 87 ++++++++++++++++++++++ bincio/sync_strava.py | 33 ++++++++- site/src/pages/admin/index.astro | 123 +++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 3 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index fe3befe..06e7583 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -164,6 +164,8 @@ public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redire dem_url: str = "https://api.open-elevation.com" # Open-Elevation-compatible API base URL sync_secret: str = "" # shared secret for /api/internal/rebuild (set via --sync-secret) _db = None # sqlite3.Connection, opened lazily +_strava_sync_running = False +_strava_sync_lock = threading.Lock() def _get_db(): @@ -1476,6 +1478,91 @@ async def admin_delete_user_directory( return JSONResponse({"ok": True}) +@app.get("/api/admin/strava-sync") +async def admin_strava_sync_status( + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Return per-user Strava sync status for the admin panel.""" + _require_admin(bincio_session) + root = _get_data_dir() + users = [] + for tf in sorted(root.glob("*/strava_token.json")): + user_dir = tf.parent + handle = user_dir.name + has_creds = (user_dir / "strava_credentials.json").exists() + + last_sync: str | None = None + total_imported = 0 + sync_path = user_dir / "_strava_sync.json" + if sync_path.exists(): + try: + sc = json.loads(sync_path.read_text(encoding="utf-8")) + last_sync = sc.get("last_sync") + total_imported = len(sc.get("imported_ids", [])) + except Exception: + pass + + 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 / "_strava_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 Exception: + pass + + users.append({ + "handle": handle, + "has_credentials": has_creds, + "last_sync": last_sync, + "total_imported": total_imported, + "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": _strava_sync_running, "users": users}) + + +@app.post("/api/admin/strava-sync/run") +async def admin_strava_sync_run( + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Trigger an immediate Strava sync for all users (admin only).""" + global _strava_sync_running + _require_admin(bincio_session) + with _strava_sync_lock: + if _strava_sync_running: + raise HTTPException(409, "Sync already running") + _strava_sync_running = True + + def _run() -> None: + global _strava_sync_running + try: + from bincio.sync_strava import sync_all + results = sync_all(_get_data_dir()) + total_new = sum(n for n, _ in results.values()) + if total_new > 0: + _site_rebuild_event.set() + except Exception: + log.exception("admin_strava_sync_run: unexpected error") + finally: + _strava_sync_running = False + + threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start() + return JSONResponse({"ok": True}, status_code=202) + + # ── Self-service user settings ──────────────────────────────────────────────── diff --git a/bincio/sync_strava.py b/bincio/sync_strava.py index 804eb49..4fc79d6 100644 --- a/bincio/sync_strava.py +++ b/bincio/sync_strava.py @@ -20,13 +20,35 @@ from pathlib import Path import click -_TOKEN_FILE = "strava_token.json" -_CREDS_FILE = "strava_credentials.json" -_SYNC_FILE = "_strava_sync.json" +_TOKEN_FILE = "strava_token.json" +_CREDS_FILE = "strava_credentials.json" +_SYNC_FILE = "_strava_sync.json" +_STATUS_FILE = "_strava_sync_status.json" log = logging.getLogger("bincio.sync_strava") +def _write_status( + user_dir: Path, + status: str, + imported: int, + errors: int, + error_message: str | None = None, +) -> None: + payload: dict = { + "status": status, + "imported": imported, + "errors": errors, + "last_run": datetime.now(timezone.utc).isoformat(), + } + if error_message is not None: + payload["error_message"] = error_message + try: + (user_dir / _STATUS_FILE).write_text(json.dumps(payload, indent=2), encoding="utf-8") + except Exception: + pass + + def _load_creds(user_dir: Path) -> tuple[str, str] | None: """Return (client_id, client_secret) from strava_credentials.json, or None.""" p = user_dir / _CREDS_FILE @@ -59,6 +81,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]: creds = _load_creds(user_dir) if creds is None: log.debug("sync[%s]: no strava_credentials.json — skipped", handle) + _write_status(user_dir, "no_credentials", 0, 0) return 0, 0 client_id, client_secret = creds @@ -67,6 +90,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]: token = ensure_fresh(user_dir, client_id, client_secret) except StravaError as exc: log.error("sync[%s]: token refresh failed: %s", handle, exc) + _write_status(user_dir, "token_error", 0, 1, str(exc)) return 0, 1 access_token = token["access_token"] @@ -89,6 +113,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]: all_acts = fetch_activities(access_token, after=after_ts) except StravaError as exc: log.error("sync[%s]: fetch_activities failed: %s", handle, exc) + _write_status(user_dir, "api_error", 0, 1, str(exc)) return 0, 1 new_acts = [a for a in all_acts if str(a["id"]) not in imported_ids] @@ -97,6 +122,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]: handle, len(new_acts), len(all_acts) - len(new_acts), ) if not new_acts: + _write_status(user_dir, "ok", 0, 0) return 0, 0 # Load existing index so we can update it in place @@ -158,6 +184,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]: merge_all(user_dir) log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors) + _write_status(user_dir, "ok", imported, errors) return imported, errors diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 2d7cdfc..72e51c5 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -31,6 +31,32 @@ import Base from '../../layouts/Base.astro'; + +
+
+

Strava Sync

+ +
+
+ + + + + + + + + + + + + + +
HandleCredentialsTotal importedLast syncLast runStatus
Loading…
+
+ +
+
@@ -460,5 +486,102 @@ import Base from '../../layouts/Base.astro'; } }); + // ── Strava sync ────────────────────────────────────────────────────────────── + + const stravaTbody = document.getElementById('strava-sync-list')!; + const stravaRunBtn = document.getElementById('strava-run-btn') as HTMLButtonElement; + const stravaRunNote = document.getElementById('strava-sync-running-note')!; + let stravaPollTimer: ReturnType | null = null; + + function fmtDate(iso: string | null): string { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); + } catch { return iso; } + } + + function stravaStatusBadge(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'], + token_error: ['text-red-400', 'Token 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 loadSync() { + try { + const r = await fetch('/api/admin/strava-sync', { credentials: 'include' }); + if (!r.ok) { + stravaTbody.innerHTML = `Error ${r.status}`; + return; + } + const { running, users } = await r.json(); + + if (running) { + stravaRunBtn.disabled = true; + stravaRunBtn.textContent = 'Running…'; + stravaRunNote.classList.remove('hidden'); + if (!stravaPollTimer) { + stravaPollTimer = setInterval(loadSync, 3000); + } + } else { + stravaRunBtn.disabled = false; + stravaRunBtn.textContent = 'Run sync now'; + stravaRunNote.classList.add('hidden'); + if (stravaPollTimer) { clearInterval(stravaPollTimer); stravaPollTimer = null; } + } + + if (!users.length) { + stravaTbody.innerHTML = 'No users with Strava connected.'; + return; + } + + stravaTbody.innerHTML = users.map((u: any) => { + const credsBadge = u.has_credentials + ? 'yes' + : 'missing'; + return ` + + @${u.handle} + ${credsBadge} + ${u.total_imported} + ${fmtDate(u.last_sync)} + ${fmtDate(u.last_run)} + ${stravaStatusBadge(u.run_status, u.run_error_message)} + `; + }).join(''); + } catch (err) { + stravaTbody.innerHTML = `${String(err)}`; + } + } + + stravaRunBtn.addEventListener('click', async () => { + stravaRunBtn.disabled = true; + stravaRunBtn.textContent = 'Starting…'; + try { + const r = await fetch('/api/admin/strava-sync/run', { method: 'POST', credentials: 'include' }); + if (r.ok) { + await loadSync(); + } else { + const d = await r.json().catch(() => ({})); + stravaRunBtn.disabled = false; + stravaRunBtn.textContent = 'Error: ' + (d.detail ?? r.status); + stravaRunBtn.classList.add('text-red-400'); + } + } catch (err) { + stravaRunBtn.disabled = false; + stravaRunBtn.textContent = 'Error'; + stravaRunBtn.classList.add('text-red-400'); + } + }); + load(); + loadSync();