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:
@@ -31,6 +31,32 @@ import Base from '../../layouts/Base.astro';
|
||||
</table>
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -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();
|
||||
loadSync();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user