From adaa075e6e59baefc2f16c1cf4a0af89aa244625 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 18 May 2026 20:54:17 +0200 Subject: [PATCH] Add usage stats script and /api/admin/stats endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/usage_stats.py: standalone script (PEP 723, runs via uv run) that parses all nginx access.log files, filters bots, maps Referer headers to feature labels, and produces a 3-panel matplotlib figure: daily logins + 7-day rolling mean, hour×weekday API heatmap, and weekly feature usage stacked area. Output saved to /var/bincio/stats/latest.png. Intended for a weekly cron job. bincio/serve/routers/admin.py: GET /api/admin/stats serves the PNG via the existing _require_admin() check — no new auth logic or nginx changes needed. Co-Authored-By: Claude Sonnet 4.6 --- bincio/serve/routers/admin.py | 12 +- scripts/usage_stats.py | 294 ++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 scripts/usage_stats.py 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