From 83994aab7b61e652376735cf11942722fa3f9c45 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 28 Jul 2026 03:13:10 +0000 Subject: [PATCH] Add an admin-only server log viewer to the web UI The root logger previously had no handler at all, so every module's logger.info() call (user creation, claims, password resets, ...) was silently dropped, not just unviewable. Adds a RotatingFileHandler writing into the existing /data volume so log content also survives container restarts/redeploys, plus /admin/logs (tail + line-count picker + full-file download) alongside the existing Users & Frames admin page. --- .claude/skills/run-server/start-server.sh | 1 + server/README.md | 10 +++- server/app/logging_setup.py | 34 +++++++++++++ server/app/main.py | 6 ++- server/app/routers/pages.py | 33 ++++++++++++- server/app/static/theme.css | 18 +++++++ server/app/templates/_admin_tabs.html | 4 ++ server/app/templates/admin.html | 2 + server/app/templates/admin_logs.html | 30 ++++++++++++ server/tests/conftest.py | 4 ++ server/tests/test_admin_logs.py | 60 +++++++++++++++++++++++ 11 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 server/app/logging_setup.py create mode 100644 server/app/templates/_admin_tabs.html create mode 100644 server/app/templates/admin_logs.html create mode 100644 server/tests/test_admin_logs.py diff --git a/.claude/skills/run-server/start-server.sh b/.claude/skills/run-server/start-server.sh index 96a1a29..3e84d04 100755 --- a/.claude/skills/run-server/start-server.sh +++ b/.claude/skills/run-server/start-server.sh @@ -11,6 +11,7 @@ mkdir -p "$SCRATCH" DATABASE_URL="sqlite:///$SCRATCH/test.db" \ CONFIG_PATH="$SCRATCH/config.json" \ +LOG_PATH="$SCRATCH/app.log" \ .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \ > "$SCRATCH/server.log" 2>&1 & PID=$! diff --git a/server/README.md b/server/README.md index 51d0e02..bbc0fb5 100644 --- a/server/README.md +++ b/server/README.md @@ -91,11 +91,19 @@ algorithm itself -- it just streams the response straight to the panel. again until a recharge is detected and it crosses again. No SMTP configured, or no email on the relevant account, and both features silently no-op rather than erroring. +- **Server logs.** `/admin/logs` shows the tail of the process's own + log file (`LOG_PATH` env var, default `/data/server.log` -- the same + `/data` volume as the database and legacy config, so it survives + container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`). + Rotates at ~2MB x 3 backups; the page only reads the current file, + "Download full log" streams it raw. There's no log shipping/ + aggregation beyond this -- it's a single-container deployment, so + the file *is* the log. ## Endpoints Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, -`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`, +`/admin`, `/admin/logs`, `/frames/{id}` (Photos), `/frames/{id}/config`, `/frames/{id}/stats`, `/m/{manage_token}`. ### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`) diff --git a/server/app/logging_setup.py b/server/app/logging_setup.py new file mode 100644 index 0000000..fb1ff58 --- /dev/null +++ b/server/app/logging_setup.py @@ -0,0 +1,34 @@ +"""Root-logger configuration: a rotating file handler under the same +/data volume as the sqlite DB and legacy config.json, so the admin log +viewer has something to read and log content survives container +restarts -- a redeploy happens on every push to main touching +server/**, which would make an in-memory-only log buffer nearly +useless in practice. Before this, the root logger had no handler at +all, so every module's logger.info() call (user creation, claims, +password resets, ...) was silently dropped rather than merely +un-viewable -- this fixes that too, not just adds a viewer.""" + +from __future__ import annotations + +import logging +import os +from logging.handlers import RotatingFileHandler +from pathlib import Path + +LOG_PATH = Path(os.environ.get("LOG_PATH", "/data/server.log")) + + +def configure_logging() -> None: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler(LOG_PATH, maxBytes=2_000_000, backupCount=3) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) + root = logging.getLogger() + root.addHandler(handler) + root.setLevel(os.environ.get("LOG_LEVEL", "INFO")) + + +def read_log_tail(lines: int) -> str: + if not LOG_PATH.exists(): + return "" + text = LOG_PATH.read_text(errors="replace") + return "\n".join(text.splitlines()[-lines:]) diff --git a/server/app/main.py b/server/app/main.py index a0d2b7c..f096a48 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -23,7 +23,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import select -from . import migration +from . import logging_setup, migration from .auth import ( browser_token_valid, current_user, @@ -38,6 +38,10 @@ from .routers.common import shell_context logger = logging.getLogger(__name__) +# Before anything else logs: a handler exists to catch it, and it lands in +# the same persistent volume the admin log viewer reads from. +logging_setup.configure_logging() + # Schema + legacy-config import, before the first request is served. migration.run_migrations() diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index 0b183aa..23aba1a 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -13,7 +13,7 @@ import logging import time from fastapi import APIRouter, Depends, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import select from sqlalchemy.orm import Session @@ -35,6 +35,7 @@ from ..auth import ( verify_password, ) from ..db import get_db +from ..logging_setup import LOG_PATH, read_log_tail from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame from .common import valid_http_url @@ -569,6 +570,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None "smtp": get_server_settings(db), "notice": notice, "error": error, + "active_admin_tab": "main", }) return templates.TemplateResponse("admin.html", ctx) @@ -583,6 +585,35 @@ def admin_page(request: Request, db: Session = Depends(get_db)): return _render_admin(request, db, user) +@router.get("/admin/logs", response_class=HTMLResponse) +def admin_logs_page(request: Request, lines: int = 500, db: Session = Depends(get_db)): + user = current_user(request, db) + if user is None: + return RedirectResponse("/login", status_code=303) + if not user.is_admin: + raise HTTPException(403, "Admin only") + from .common import shell_context + + lines = max(50, min(lines, 5000)) + ctx = shell_context(request, db, user, active_nav="admin") + ctx.update({ + "active_admin_tab": "logs", + "log_exists": LOG_PATH.exists(), + "log_path": str(LOG_PATH), + "log_lines": lines, + "log_text": read_log_tail(lines), + }) + return templates.TemplateResponse("admin_logs.html", ctx) + + +@router.get("/admin/logs/download") +def admin_logs_download(request: Request, db: Session = Depends(get_db)): + _require_admin_page(request, db) + if not LOG_PATH.exists(): + raise HTTPException(404, "No log file yet") + return FileResponse(LOG_PATH, filename="server.log", media_type="text/plain") + + @router.post("/admin/users", response_class=HTMLResponse) def admin_create_user( request: Request, diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 0b78104..63d4902 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -156,6 +156,24 @@ button.linklike:hover { color: var(--text); background: none; } .admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; } .admin-inline-form input[type="text"] { margin-top: 0; flex: 1; } +.log-view-controls { display: flex; gap: 10px; align-items: center; margin: 10px 0; font-size: 13px; } +.log-view-controls a:not(.btn-inline) { color: var(--text-muted); } +.log-view-controls a.active { color: var(--accent); font-weight: 600; } +.log-view { + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + max-height: 65vh; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + font-family: ui-monospace, "SF Mono", Consolas, monospace; + font-size: 12.5px; + line-height: 1.5; + color: var(--text); +} + h2.card-title, summary.card-title { font-size: 14.5px; font-weight: 650; diff --git a/server/app/templates/_admin_tabs.html b/server/app/templates/_admin_tabs.html new file mode 100644 index 0000000..d07b4bf --- /dev/null +++ b/server/app/templates/_admin_tabs.html @@ -0,0 +1,4 @@ + diff --git a/server/app/templates/admin.html b/server/app/templates/admin.html index d5593c9..d7db74d 100644 --- a/server/app/templates/admin.html +++ b/server/app/templates/admin.html @@ -4,6 +4,8 @@ {% block page_title %}Administration{% endblock %} {% block content %} + {% include "_admin_tabs.html" %} + {% if notice %}
{{ notice }}
{% endif %} {% if error %}
{{ error }}
{% endif %} diff --git a/server/app/templates/admin_logs.html b/server/app/templates/admin_logs.html new file mode 100644 index 0000000..e80ee31 --- /dev/null +++ b/server/app/templates/admin_logs.html @@ -0,0 +1,30 @@ +{% extends "app_base.html" %} + +{% block title %}Server Logs{% endblock %} +{% block page_title %}Administration{% endblock %} + +{% block content %} + {% include "_admin_tabs.html" %} + +
+
+

Server Logs

+ Download full log +
+ + {% if not log_exists %} +

No log file yet -- nothing has been logged since this server last started.

+ {% else %} +

Last {{ log_lines }} lines of {{ log_path }}. Rotates at ~2MB + (older entries roll into {{ log_path }}.1, etc. -- not shown here; use + "Download full log" for just the current file).

+
+ {% for n in [200, 500, 2000, 5000] %} + {{ n }} + {% endfor %} + Refresh +
+
{{ log_text }}
+ {% endif %} +
+{% endblock %} diff --git a/server/tests/conftest.py b/server/tests/conftest.py index be952f1..6a9feb7 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -29,6 +29,10 @@ from pathlib import Path _tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_") os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}" +# Same reasoning as DATABASE_URL above: logging_setup.configure_logging() +# also runs as an app.main import-time side effect and would otherwise +# try to create the real /data directory. +os.environ["LOG_PATH"] = str(Path(_tmp_dir) / "server.log") import pytest from fastapi.testclient import TestClient diff --git a/server/tests/test_admin_logs.py b/server/tests/test_admin_logs.py new file mode 100644 index 0000000..9185f5d --- /dev/null +++ b/server/tests/test_admin_logs.py @@ -0,0 +1,60 @@ +"""Permission boundary + basic content checks for the admin log viewer +(routers/pages.py's admin_logs_page/admin_logs_download) -- see +CLAUDE.md's note that anything gated by an admin/permission check needs +a same-shape test: admin, non-admin logged in, logged out.""" + +from __future__ import annotations + +import logging + +from app.logging_setup import LOG_PATH + +from .conftest import login, make_user + + +def _setup_admin_and_user(client, db_session) -> None: + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + make_user(db_session, "bob") + + +def test_admin_can_view_logs(client, db_session): + _setup_admin_and_user(client, db_session) + login(client, "alice", "hunter22") + logging.getLogger("app.test").info("marker-line-for-test") + resp = client.get("/admin/logs") + assert resp.status_code == 200 + assert "marker-line-for-test" in resp.text + + +def test_non_admin_forbidden_from_logs(client, db_session): + _setup_admin_and_user(client, db_session) + login(client, "bob") + resp = client.get("/admin/logs") + assert resp.status_code == 403 + resp = client.get("/admin/logs/download") + assert resp.status_code == 403 + + +def test_logged_out_redirected_from_logs_page(client, db_session): + _setup_admin_and_user(client, db_session) + client.cookies.clear() # /setup itself logs alice in + resp = client.get("/admin/logs") + assert resp.status_code == 303 + assert resp.headers["location"] == "/login" + + +def test_admin_can_download_log_file(client, db_session): + _setup_admin_and_user(client, db_session) + login(client, "alice", "hunter22") + logging.getLogger("app.test").info("marker-line-for-download") + resp = client.get("/admin/logs/download") + assert resp.status_code == 200 + assert b"marker-line-for-download" in resp.content + + +def test_download_404s_before_any_log_written(client, db_session, monkeypatch): + _setup_admin_and_user(client, db_session) + login(client, "alice", "hunter22") + monkeypatch.setattr("app.routers.pages.LOG_PATH", LOG_PATH.parent / "does-not-exist.log") + resp = client.get("/admin/logs/download") + assert resp.status_code == 404