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'; + +
| Handle | +Credentials | +Total imported | +Last sync | +Last run | +Status | +
|---|---|---|---|---|---|
| Loading… | |||||