diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 4c74fbf..9a94e70 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -243,16 +243,19 @@ def _trigger_rebuild(handle: str) -> None: def _run() -> None: try: if _webroot is None: - # Fast: only update data, skip Astro build - subprocess.run( - [uv, "run", "bincio", "render", - "--data-dir", _data_dir, - "--site-dir", _site_dir, - "--handle", _handle, - "--no-build"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + # Fast: only update data, skip Astro build. + # Serialised with the same lock: merge_all wipes and recreates + # _merged/activities/ — concurrent runs would corrupt each other. + with _rebuild_lock: + subprocess.run( + [uv, "run", "bincio", "render", + "--data-dir", _data_dir, + "--site-dir", _site_dir, + "--handle", _handle, + "--no-build"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) else: # Full build + rsync — serialised so concurrent uploads don't race with _rebuild_lock: @@ -500,6 +503,20 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS }) +@app.post("/api/admin/users/{handle}/rebuild") +async def admin_rebuild( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Trigger a merge_all + site rebuild for a user. Admin only.""" + _require_admin(bincio_session) + user_dir = _get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for user '{handle}'") + _trigger_rebuild(handle) + return JSONResponse({"ok": True}) + + @app.delete("/api/admin/users/{handle}/activities") async def admin_delete_activities( handle: str, diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 9070c77..c67ac33 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -115,15 +115,50 @@ import Base from '../../layouts/Base.astro'; ${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'} ${u.images_mb > 0 ? fmt(u.images_mb) : '—'} - +
+ + +
`; }).join(''); + tbodyEl.querySelectorAll('.rebuild-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const h = btn.dataset.handle!; + btn.disabled = true; + btn.textContent = 'Queued…'; + try { + const r = await fetch(`/api/admin/users/${h}/rebuild`, { + method: 'POST', + credentials: 'include', + }); + if (r.ok) { + btn.textContent = 'Rebuilding…'; + btn.classList.add('text-blue-400'); + // Rebuild is async — reload sizes after a delay + setTimeout(() => load(), 8000); + } else { + btn.disabled = false; + btn.textContent = 'Error'; + btn.classList.add('text-red-400'); + } + } catch { + btn.disabled = false; + btn.textContent = 'Error'; + btn.classList.add('text-red-400'); + } + }); + }); + tbodyEl.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', () => { pendingHandle = btn.dataset.handle!;