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 %}
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).
{{ log_text }}
+ {% endif %}
+