diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 6518728..4c74fbf 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -106,6 +106,19 @@ def _get_data_dir() -> Path: app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None) + +@app.on_event("startup") +async def _cleanup_orphaned_tmp_zips() -> None: + """Remove tmp*.zip files left in user data dirs by the pre-fix upload handler.""" + import glob as _glob + data_dir = _get_data_dir() + for p in _glob.glob(str(data_dir / "*" / "tmp*.zip")): + try: + Path(p).unlink() + except Exception: + pass + + app.add_middleware(GZipMiddleware, minimum_size=1024) app.add_middleware( CORSMiddleware, @@ -437,6 +450,56 @@ async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JS return JSONResponse(jobs) +@app.get("/api/admin/disk") +async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Per-user disk usage breakdown. Admin only.""" + _require_admin(bincio_session) + import shutil + + data_dir = _get_data_dir() + + def _mb(path: Path) -> float: + if not path.exists(): + return 0.0 + total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + return round(total / 1_048_576, 1) + + def _count(path: Path, pattern: str = "*") -> int: + if not path.exists(): + return 0 + return sum(1 for f in path.glob(pattern) if f.is_file()) + + users = [] + for user_dir in sorted(data_dir.iterdir()): + if not user_dir.is_dir() or user_dir.name.startswith("_"): + continue + # leaked tmp zips + leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()] + users.append({ + "handle": user_dir.name, + "total_mb": _mb(user_dir), + "activities_mb": _mb(user_dir / "activities"), + "activities_count": _count(user_dir / "activities", "*.json"), + "merged_mb": _mb(user_dir / "_merged"), + "originals_mb": _mb(user_dir / "originals"), + "originals_strava_mb": _mb(user_dir / "originals" / "strava"), + "images_mb": _mb(user_dir / "edits" / "images"), + "leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1), + "leaked_zips_count": len(leaked), + }) + + disk = shutil.disk_usage("/") + return JSONResponse({ + "disk": { + "total_gb": round(disk.total / 1_073_741_824, 1), + "used_gb": round(disk.used / 1_073_741_824, 1), + "free_gb": round(disk.free / 1_073_741_824, 1), + "percent": round(disk.used / disk.total * 100, 1), + }, + "users": users, + }) + + @app.delete("/api/admin/users/{handle}/activities") async def admin_delete_activities( handle: str, @@ -803,7 +866,7 @@ async def upload_strava_zip( dd = _get_data_dir() / user.handle import tempfile - tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd) + tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) zip_path = Path(tmp.name) try: while chunk := await file.read(1024 * 1024): # 1 MB chunks @@ -826,8 +889,9 @@ async def upload_strava_zip( merge_all(dd) _trigger_rebuild(user.handle) except Exception as exc: - zip_path.unlink(missing_ok=True) yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + finally: + zip_path.unlink(missing_ok=True) return StreamingResponse( event_stream(), diff --git a/scripts/disk_report.sh b/scripts/disk_report.sh new file mode 100644 index 0000000..a7ce70a --- /dev/null +++ b/scripts/disk_report.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Bincio VPS disk usage report +# Run on the VPS: bash scripts/disk_report.sh +# Or remotely: ssh root@ 'bash -s' < scripts/disk_report.sh + +DATA=/var/bincio/data +SITE=/var/bincio/site # adjust if your site build lives elsewhere + +hr() { echo; echo "── $* ──────────────────────────────────────"; } + +hr "DISK OVERVIEW" +df -h / | tail -1 | awk '{printf "Used: %s / %s (%s full)\n", $3, $2, $5}' + +hr "BINCIO ROOT" +du -sh /var/bincio/ 2>/dev/null + +hr "DATA ROOT: $DATA" +du -sh "$DATA" 2>/dev/null + +hr "PER-USER BREAKDOWN" +for user_dir in "$DATA"/*/; do + handle=$(basename "$user_dir") + [[ "$handle" == _* ]] && continue # skip _feedback etc. + + total=$(du -sh "$user_dir" 2>/dev/null | cut -f1) + + act=$(du -sh "$user_dir/activities" 2>/dev/null | cut -f1 || echo "—") + merged=$(du -sh "$user_dir/_merged" 2>/dev/null | cut -f1 || echo "—") + edits=$(du -sh "$user_dir/edits" 2>/dev/null | cut -f1 || echo "—") + images=$(du -sh "$user_dir/edits/images" 2>/dev/null | cut -f1 || echo "—") + orig=$(du -sh "$user_dir/originals" 2>/dev/null | cut -f1 || echo "—") + orig_strava=$(du -sh "$user_dir/originals/strava" 2>/dev/null | cut -f1 || echo "—") + orig_fit=$(du -sh "$user_dir/originals" 2>/dev/null) # will count below by extension + + n_act=$(find "$user_dir/activities" -name "*.json" 2>/dev/null | wc -l | tr -d ' ') + n_orig=$(find "$user_dir/originals" -type f 2>/dev/null | wc -l | tr -d ' ') + n_strava=$(find "$user_dir/originals/strava" -name "*.json" 2>/dev/null | wc -l | tr -d ' ') + + echo "" + echo " @$handle (total: $total)" + echo " activities/ $act ($n_act JSON files)" + echo " _merged/ $merged" + echo " edits/ $edits (images: $images)" + echo " originals/ $orig ($n_orig files)" + echo " strava/ $orig_strava ($n_strava JSON)" +done + +hr "FEEDBACK" +du -sh "$DATA/_feedback" 2>/dev/null || echo " (none)" + +hr "SITE BUILD" +du -sh "$SITE" 2>/dev/null || echo " (not found at $SITE)" + +hr "LOGS" +journalctl --disk-usage 2>/dev/null || echo " (journalctl unavailable)" + +hr "LARGEST FILES IN DATA (top 20)" +find "$DATA" -type f -printf '%s\t%p\n' 2>/dev/null \ + | sort -rn | head -20 \ + | awk '{ + size=$1; path=$2; + if (size >= 1048576) printf "%6.1f MB %s\n", size/1048576, path; + else if (size >= 1024) printf "%6.1f KB %s\n", size/1024, path; + else printf "%6d B %s\n", size, path; + }' + +hr "EXTENSION BREAKDOWN IN originals/" +find "$DATA" -path "*/originals/*" -type f 2>/dev/null \ + | sed 's/.*\.//' | sort | uniq -c | sort -rn \ + | awk '{printf " %6d .%s\n", $1, $2}' + +echo diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 89ab8bf..9070c77 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -2,12 +2,33 @@ import Base from '../../layouts/Base.astro'; --- -
+

Admin

+ +
+

Loading disk info…

+
+ +

Users

-
-

Loading…

+
+ + + + + + + + + + + + + + + +
HandleTotalActivitiesOriginalsMergedImages
Loading…
@@ -22,40 +43,88 @@ import Base from '../../layouts/Base.astro';