diff --git a/bincio/serve/routers/admin.py b/bincio/serve/routers/admin.py index 4b294a0..ede2d27 100644 --- a/bincio/serve/routers/admin.py +++ b/bincio/serve/routers/admin.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any from fastapi import APIRouter, Cookie, HTTPException, Request -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from bincio.serve import deps, tasks from bincio.serve.models import ResetPasswordCodeResponse @@ -58,6 +58,16 @@ def _wipe_user_activities(user_dir: Path) -> int: return deleted +@router.get("/api/admin/stats") +async def admin_stats(bincio_session: str | None = Cookie(default=None)) -> FileResponse: + """Serve the latest usage stats figure. Admin only.""" + deps._require_admin(bincio_session) + path = deps._get_data_dir().parent / "stats" / "latest.png" + if not path.exists(): + raise HTTPException(404, "Stats not yet generated — run scripts/usage_stats.py first") + return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache, no-store"}) + + @router.get("/api/admin/users") async def admin_users(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: deps._require_admin(bincio_session) diff --git a/scripts/usage_stats.py b/scripts/usage_stats.py new file mode 100644 index 0000000..44d59a4 --- /dev/null +++ b/scripts/usage_stats.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["matplotlib>=3.9", "pandas>=2.2"] +# /// +""" +Bincio usage statistics — parses nginx access logs and produces a +multi-panel matplotlib figure saved as a PNG. + +Run locally: uv run scripts/usage_stats.py +On VPS cron: 0 3 * * 1 cd /opt/bincio && uv run scripts/usage_stats.py +Output: /var/bincio/stats/latest.png (served at /api/admin/stats) +""" +from __future__ import annotations + +import argparse +import gzip +import re +import sys +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.ticker as ticker +import numpy as np +import pandas as pd + +# ── Config ──────────────────────────────────────────────────────────────────── + +LOG_DIR = Path("/var/log/nginx") +OUTPUT_DIR = Path("/var/bincio/stats") +OUTPUT = OUTPUT_DIR / "latest.png" + +# ── Log parsing ─────────────────────────────────────────────────────────────── + +_LOG_RE = re.compile( + r'(?P\S+) \S+ \S+ \[(?P