From 418b40cd3a01f9062e906b40489d7da42aaeb403 Mon Sep 17 00:00:00 2001 From: Mark <20665331+markbeep@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:01:00 +0200 Subject: [PATCH] feat: add logs page on settings page. Closes #18 --- app/routers/pages/settings/__init__.py | 2 + app/routers/pages/settings/logs.py | 72 +++++++++++++++++++++++ app/util/log.py | 32 ++++++++++ templates/layouts/BaseLayout.jinja | 5 +- templates/layouts/SettingsLayout.jinja | 13 +++- templates/pages/Settings/Logs/Index.jinja | 60 +++++++++++++++++++ tests/test_page_endpoints.py | 35 +++++++++++ 7 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 app/routers/pages/settings/logs.py create mode 100644 templates/pages/Settings/Logs/Index.jinja diff --git a/app/routers/pages/settings/__init__.py b/app/routers/pages/settings/__init__.py index e38d555d..104297cd 100644 --- a/app/routers/pages/settings/__init__.py +++ b/app/routers/pages/settings/__init__.py @@ -6,6 +6,7 @@ chaptarr, download, indexers, + logs, notification, prowlarr, security, @@ -19,6 +20,7 @@ router.include_router(chaptarr.router) router.include_router(download.router) router.include_router(indexers.router) +router.include_router(logs.router) router.include_router(notification.router) router.include_router(prowlarr.router) router.include_router(security.router) diff --git a/app/routers/pages/settings/logs.py b/app/routers/pages/settings/logs.py new file mode 100644 index 00000000..4c0535d0 --- /dev/null +++ b/app/routers/pages/settings/logs.py @@ -0,0 +1,72 @@ +from collections.abc import Sequence +from typing import Annotated +from urllib.parse import urlencode + +from fastapi import APIRouter, Query, Security + +from app.internal.auth.authentication import ABRAuth, DetailedUser +from app.internal.env_settings import Settings +from app.internal.models import GroupEnum +from app.util.log import LogEntry, snapshot_logs +from app.util.templates import catalog_response + +router = APIRouter(prefix="/logs") + +LOG_LEVELS = ("debug", "info", "warning", "error") +PAGE_SIZE = 250 + + +def _logs_url(levels: Sequence[str], offset: int = 0) -> str: + params: dict[str, str] = {} + if set(levels) != set(LOG_LEVELS): + params["level"] = ",".join(levels) + if offset > 0: + params["offset"] = str(offset) + base = Settings().app.base_url.rstrip("/") + return f"{base}/settings/logs" + (f"?{urlencode(params)}" if params else "") + + +@router.get("") +def read_logs( + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], + level: Annotated[str | None, Query()] = None, + offset: Annotated[int, Query(ge=0)] = 0, +): + if level is None: + selected_levels = list(LOG_LEVELS) + else: + requested = {lvl.strip().lower() for lvl in level.split(",")} + selected_levels = [lvl for lvl in LOG_LEVELS if lvl in requested] + + matching: list[LogEntry] = [ + entry for entry in snapshot_logs() if entry["level"] in selected_levels + ] + offset = min(offset, len(matching)) + end = len(matching) - offset + start = max(0, end - PAGE_SIZE) + logs = matching[start:end] + + toggle_urls: dict[str, str] = {} + for lvl in LOG_LEVELS: + if lvl in selected_levels: + toggled = [other for other in selected_levels if other != lvl] + else: + toggled = [ + other + for other in LOG_LEVELS + if other in selected_levels or other == lvl + ] + toggle_urls[lvl] = _logs_url(toggled) + + return catalog_response( + "Settings.Logs.Index", + user=admin_user, + logs=logs, + log_levels=list(LOG_LEVELS), + selected_levels=selected_levels, + toggle_urls=toggle_urls, + older_url=_logs_url(selected_levels, offset + PAGE_SIZE) if start > 0 else None, + newer_url=_logs_url(selected_levels, max(0, offset - PAGE_SIZE)) + if offset > 0 + else None, + ) diff --git a/app/util/log.py b/app/util/log.py index 4c143010..3cde448c 100644 --- a/app/util/log.py +++ b/app/util/log.py @@ -1,6 +1,37 @@ import logging +import threading +from collections import deque +from typing import TypedDict import structlog +from structlog.typing import EventDict + + +class LogEntry(TypedDict): + level: str + message: str + + +# in-memory only, reset on restart. shortcut: capped so a long-running instance cannot grow unbounded, raise if full history is needed +logs: deque[LogEntry] = deque(maxlen=10_000) +_logs_lock = threading.Lock() + +_plain_renderer = structlog.dev.ConsoleRenderer(colors=False) + + +def snapshot_logs() -> list[LogEntry]: + with _logs_lock: + return list(logs) + + +def _capture_log_event( + logger: logging.Logger, method_name: str, event_dict: EventDict +) -> EventDict: + message = _plain_renderer(logger, method_name, dict(event_dict)) + with _logs_lock: + logs.append({"level": method_name, "message": message}) + return event_dict + structlog.configure( processors=[ @@ -9,6 +40,7 @@ structlog.processors.StackInfoRenderer(), structlog.dev.set_exc_info, structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False), + _capture_log_event, structlog.dev.ConsoleRenderer(), ], wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET), diff --git a/templates/layouts/BaseLayout.jinja b/templates/layouts/BaseLayout.jinja index f1c558c3..dae77fb9 100644 --- a/templates/layouts/BaseLayout.jinja +++ b/templates/layouts/BaseLayout.jinja @@ -2,6 +2,7 @@ user: DetailedUser | None = None, title="AudiobookRequest", hide_navbar=False, + full_height: bool = False, #} @@ -78,7 +79,9 @@ - + diff --git a/templates/layouts/SettingsLayout.jinja b/templates/layouts/SettingsLayout.jinja index 23dd03dc..51296731 100644 --- a/templates/layouts/SettingsLayout.jinja +++ b/templates/layouts/SettingsLayout.jinja @@ -2,9 +2,10 @@ user: DetailedUser, title: str, page: str, + fill: bool = False, #} - + {% if version %}