Add an admin-only server log viewer to the web UI
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 2m35s
Build and push server image / deploy (push) Successful in 51s

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.
This commit is contained in:
2026-07-28 03:13:10 +00:00
parent 5866c2f040
commit 83994aab7b
11 changed files with 199 additions and 3 deletions
@@ -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=$!
+9 -1
View File
@@ -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=`)
+34
View File
@@ -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:])
+5 -1
View File
@@ -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()
+32 -1
View File
@@ -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,
+18
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
<nav class="tabs">
<a href="/admin" class="{% if active_admin_tab == 'main' %}active{% endif %}">Users &amp; Frames</a>
<a href="/admin/logs" class="{% if active_admin_tab == 'logs' %}active{% endif %}">Server Logs</a>
</nav>
+2
View File
@@ -4,6 +4,8 @@
{% block page_title %}Administration{% endblock %}
{% block content %}
{% include "_admin_tabs.html" %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "app_base.html" %}
{% block title %}Server Logs{% endblock %}
{% block page_title %}Administration{% endblock %}
{% block content %}
{% include "_admin_tabs.html" %}
<section class="card">
<div class="card-title-row">
<h2 class="card-title">Server Logs</h2>
<a href="/admin/logs/download" class="secondary btn-inline">Download full log</a>
</div>
{% if not log_exists %}
<p class="sub">No log file yet -- nothing has been logged since this server last started.</p>
{% else %}
<p class="sub">Last {{ log_lines }} lines of <code>{{ log_path }}</code>. Rotates at ~2MB
(older entries roll into <code>{{ log_path }}.1</code>, etc. -- not shown here; use
"Download full log" for just the current file).</p>
<div class="log-view-controls">
{% for n in [200, 500, 2000, 5000] %}
<a href="/admin/logs?lines={{ n }}" class="{% if log_lines == n %}active{% endif %}">{{ n }}</a>
{% endfor %}
<a href="/admin/logs?lines={{ log_lines }}" class="secondary btn-inline">Refresh</a>
</div>
<pre class="log-view">{{ log_text }}</pre>
{% endif %}
</section>
{% endblock %}
+4
View File
@@ -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
+60
View File
@@ -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