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 @@