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.
This commit is contained in:
@@ -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
|
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)
|
sync_secret: str = "" # shared secret for /api/internal/rebuild (set via --sync-secret)
|
||||||
_db = None # sqlite3.Connection, opened lazily
|
_db = None # sqlite3.Connection, opened lazily
|
||||||
|
_strava_sync_running = False
|
||||||
|
_strava_sync_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _get_db():
|
def _get_db():
|
||||||
@@ -1476,6 +1478,91 @@ async def admin_delete_user_directory(
|
|||||||
return JSONResponse({"ok": True})
|
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 ────────────────────────────────────────────────
|
# ── Self-service user settings ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,32 @@ import click
|
|||||||
_TOKEN_FILE = "strava_token.json"
|
_TOKEN_FILE = "strava_token.json"
|
||||||
_CREDS_FILE = "strava_credentials.json"
|
_CREDS_FILE = "strava_credentials.json"
|
||||||
_SYNC_FILE = "_strava_sync.json"
|
_SYNC_FILE = "_strava_sync.json"
|
||||||
|
_STATUS_FILE = "_strava_sync_status.json"
|
||||||
|
|
||||||
log = logging.getLogger("bincio.sync_strava")
|
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:
|
def _load_creds(user_dir: Path) -> tuple[str, str] | None:
|
||||||
"""Return (client_id, client_secret) from strava_credentials.json, or None."""
|
"""Return (client_id, client_secret) from strava_credentials.json, or None."""
|
||||||
p = user_dir / _CREDS_FILE
|
p = user_dir / _CREDS_FILE
|
||||||
@@ -59,6 +81,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]:
|
|||||||
creds = _load_creds(user_dir)
|
creds = _load_creds(user_dir)
|
||||||
if creds is None:
|
if creds is None:
|
||||||
log.debug("sync[%s]: no strava_credentials.json — skipped", handle)
|
log.debug("sync[%s]: no strava_credentials.json — skipped", handle)
|
||||||
|
_write_status(user_dir, "no_credentials", 0, 0)
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
client_id, client_secret = creds
|
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)
|
token = ensure_fresh(user_dir, client_id, client_secret)
|
||||||
except StravaError as exc:
|
except StravaError as exc:
|
||||||
log.error("sync[%s]: token refresh failed: %s", handle, exc)
|
log.error("sync[%s]: token refresh failed: %s", handle, exc)
|
||||||
|
_write_status(user_dir, "token_error", 0, 1, str(exc))
|
||||||
return 0, 1
|
return 0, 1
|
||||||
|
|
||||||
access_token = token["access_token"]
|
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)
|
all_acts = fetch_activities(access_token, after=after_ts)
|
||||||
except StravaError as exc:
|
except StravaError as exc:
|
||||||
log.error("sync[%s]: fetch_activities failed: %s", handle, exc)
|
log.error("sync[%s]: fetch_activities failed: %s", handle, exc)
|
||||||
|
_write_status(user_dir, "api_error", 0, 1, str(exc))
|
||||||
return 0, 1
|
return 0, 1
|
||||||
|
|
||||||
new_acts = [a for a in all_acts if str(a["id"]) not in imported_ids]
|
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),
|
handle, len(new_acts), len(all_acts) - len(new_acts),
|
||||||
)
|
)
|
||||||
if not new_acts:
|
if not new_acts:
|
||||||
|
_write_status(user_dir, "ok", 0, 0)
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
# Load existing index so we can update it in place
|
# 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)
|
merge_all(user_dir)
|
||||||
|
|
||||||
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors)
|
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors)
|
||||||
|
_write_status(user_dir, "ok", imported, errors)
|
||||||
return imported, errors
|
return imported, errors
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,32 @@ import Base from '../../layouts/Base.astro';
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Strava sync -->
|
||||||
|
<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">Strava Sync</h2>
|
||||||
|
<button id="strava-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">Credentials</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-right">Total imported</th>
|
||||||
|
<th class="px-4 py-2 font-medium">Last sync</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="strava-sync-list">
|
||||||
|
<tr><td colspan="6" class="px-4 py-6 text-zinc-500 text-center">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="strava-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">
|
||||||
@@ -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<typeof setInterval> | 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 '<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'],
|
||||||
|
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
|
||||||
|
? `<span class="block text-zinc-500 text-xs truncate max-w-xs" title="${errMsg.replace(/"/g, '"')}">${errMsg.slice(0, 80)}</span>`
|
||||||
|
: '';
|
||||||
|
return `<span class="${color} font-medium text-xs">${label}</span>${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSync() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/admin/strava-sync', { credentials: 'include' });
|
||||||
|
if (!r.ok) {
|
||||||
|
stravaTbody.innerHTML = `<tr><td colspan="6" class="px-4 py-4 text-red-400 text-center">Error ${r.status}</td></tr>`;
|
||||||
|
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 = '<tr><td colspan="6" class="px-4 py-6 text-zinc-500 text-center">No users with Strava connected.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stravaTbody.innerHTML = users.map((u: any) => {
|
||||||
|
const credsBadge = u.has_credentials
|
||||||
|
? '<span class="text-green-400 text-xs">yes</span>'
|
||||||
|
: '<span class="text-amber-400 text-xs">missing</span>';
|
||||||
|
return `
|
||||||
|
<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">${credsBadge}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-zinc-300 tabular-nums">${u.total_imported}</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-400 text-xs">${fmtDate(u.last_sync)}</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-400 text-xs">${fmtDate(u.last_run)}</td>
|
||||||
|
<td class="px-4 py-3">${stravaStatusBadge(u.run_status, u.run_error_message)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
stravaTbody.innerHTML = `<tr><td colspan="6" class="px-4 py-4 text-red-400 text-center">${String(err)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
load();
|
||||||
|
loadSync();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user