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.
This commit is contained in:
@@ -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
@@ -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=`)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<nav class="tabs">
|
||||
<a href="/admin" class="{% if active_admin_tab == 'main' %}active{% endif %}">Users & Frames</a>
|
||||
<a href="/admin/logs" class="{% if active_admin_tab == 'logs' %}active{% endif %}">Server Logs</a>
|
||||
</nav>
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user