From 4441c5a24d5c71e79ebc6548cd5cb0fca133685d Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 20:08:52 +0100 Subject: [PATCH 1/2] feat(system): report health problems and keep recent log entries --- tests/test_import_flow.py | 31 +++++++++++++++++++++++++++ wordarr/api/system.py | 44 +++++++++++++++++++++++++++++++++++++++ wordarr/logs.py | 30 ++++++++++++++++++++++++++ wordarr/main.py | 7 +++++-- 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 wordarr/api/system.py create mode 100644 wordarr/logs.py diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 81296f5..3262a0d 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1328,3 +1328,34 @@ def test_adopt_files_already_in_the_library(client): assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved assert client.get(f"/api/import/library/{lib}").json()["items"] == [] assert [h["event"] for h in client.get("/api/history").json()] == ["adopted"] * 2 + + +def test_health_reports_problems_as_codes(client, monkeypatch): + from wordarr import connect + from wordarr.metadata import hardcover + monkeypatch.setattr(hardcover, "TOKEN", "") + monkeypatch.setattr(connect, "ABS_URL", "http://abs") + monkeypatch.setattr(connect, "ABS_TOKEN", "") + gone = client.tmp_path / "gone" + client.post("/api/libraries", json={ + "name": "Weg", "media_type": "ebook", "root_path": str(gone)}) + client.post("/api/libraries", json={ + "name": "Da", "media_type": "ebook", "root_path": str(client.downloads)}) + + issues = client.get("/api/system/health").json() + assert {(i["code"], i.get("name")) for i in issues} == { + ("library.missing", "Weg"), ("hardcover.token", None), ("abs.incomplete", None)} + + monkeypatch.setattr(hardcover, "TOKEN", "x") + monkeypatch.setattr(connect, "ABS_URL", "") + gone.mkdir() + assert client.get("/api/system/health").json() == [] + + +def test_recent_logs_newest_first(client): + import logging + logging.getLogger("wordarr.test").info("first") + logging.getLogger("wordarr.test").warning("second") + rows = client.get("/api/system/logs").json() + assert [r["message"] for r in rows[:2]] == ["second", "first"] + assert rows[0]["level"] == "WARNING" diff --git a/wordarr/api/system.py b/wordarr/api/system.py new file mode 100644 index 0000000..384fab8 --- /dev/null +++ b/wordarr/api/system.py @@ -0,0 +1,44 @@ +import os +from pathlib import Path + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .. import config, connect, logs +from ..db import Library, get_session +from ..metadata import hardcover + +router = APIRouter(prefix="/api/system", tags=["system"]) + + +def _dir_problem(path: Path) -> str | None: + if not path.is_dir(): + return "missing" + if not os.access(path, os.W_OK | os.X_OK): + return "readonly" + return None + + +@router.get("/health") +def health(session: Session = Depends(get_session)): + """Problems only, as codes the UI translates; an empty list means healthy.""" + issues = [] + if problem := _dir_problem(config.DOWNLOAD_DIR): + issues.append({"code": f"download.{problem}", "level": "error", + "path": str(config.DOWNLOAD_DIR)}) + for lib in session.scalars(select(Library).order_by(Library.name)): + if problem := _dir_problem(Path(lib.root_path)): + issues.append({"code": f"library.{problem}", "level": "error", + "name": lib.name, "path": lib.root_path}) + if not hardcover.TOKEN: + issues.append({"code": "hardcover.token", "level": "warning"}) + if bool(connect.ABS_URL) != bool(connect.ABS_TOKEN): + issues.append({"code": "abs.incomplete", "level": "warning"}) + return issues + + +@router.get("/logs") +def recent_logs(): + """Newest first.""" + return list(reversed(logs.RECORDS)) diff --git a/wordarr/logs.py b/wordarr/logs.py new file mode 100644 index 0000000..b9551ff --- /dev/null +++ b/wordarr/logs.py @@ -0,0 +1,30 @@ +"""Keep the latest log records in memory for the system page.""" +import collections +import datetime +import logging + +# shortcut: lost on restart, write to /config if older entries are ever needed +RECORDS: collections.deque[dict] = collections.deque(maxlen=200) + + +class _Buffer(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + RECORDS.append({ + "at": datetime.datetime.fromtimestamp(record.created, datetime.UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": self.format(record), + }) + + +def install() -> None: + """A handler on root would silence the last-resort stderr output, so + basicConfig keeps the container log. uvicorn's loggers do not propagate, + its errors are caught on uvicorn.error itself.""" + if any(isinstance(h, _Buffer) for h in logging.getLogger().handlers): + return + logging.basicConfig(format="%(levelname)s:%(name)s: %(message)s") + logging.getLogger("wordarr").setLevel(logging.INFO) + buffer = _Buffer(logging.INFO) + logging.getLogger().addHandler(buffer) + logging.getLogger("uvicorn.error").addHandler(buffer) diff --git a/wordarr/main.py b/wordarr/main.py index c9cd3c3..b8d2e45 100644 --- a/wordarr/main.py +++ b/wordarr/main.py @@ -4,8 +4,10 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from . import db, version -from .api import fs, history, imports, libraries, requests, search +from . import db, logs, version +from .api import fs, history, imports, libraries, requests, search, system + +logs.install() @asynccontextmanager @@ -22,6 +24,7 @@ app.include_router(requests.router) app.include_router(imports.router) app.include_router(fs.router) app.include_router(history.router) +app.include_router(system.router) @app.get("/api/version") From 175dface50150fcb464d8b6887c10a9c1d9ceb16 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 20:09:06 +0100 Subject: [PATCH 2/2] feat(ui): add a system page with health checks, version and logs --- static/app.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++- static/i18n.js | 28 ++++++++++++++++++++++ static/index.html | 25 ++++++++++++++++++++ static/style.css | 7 ++++++ 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/static/app.js b/static/app.js index 3dbe386..ead96b9 100644 --- a/static/app.js +++ b/static/app.js @@ -93,7 +93,7 @@ async function refreshMissingBadge() { // ---- navigation ---- // the view lives in the hash so reload, back button and links keep it -const VIEWS = ["search", "missing", "imported", "import", "activity", "settings"]; +const VIEWS = ["search", "missing", "imported", "import", "activity", "settings", "system"]; function showView() { const hash = location.hash.slice(1); @@ -102,6 +102,7 @@ function showView() { $$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name)); if (name === "missing" || name === "imported") loadRequests(name); if (name === "activity") loadHistory(); + if (name === "system") loadSystem(); if (name === "settings") loadLibraries().catch((err) => toast(err.message, true)); } @@ -117,6 +118,7 @@ window.addEventListener("hashchange", showView); // ---- libraries ---- async function loadLibraries() { libraries = await api("/api/libraries"); + refreshHealth(); const tbody = $("#lib-table tbody"); tbody.innerHTML = libraries .map( @@ -699,6 +701,59 @@ function renderHistory() { .join(""); } +// ---- system ---- +let healthIssues = []; +let logRows = []; + +// also called after library changes, so a fixed root path clears the badge +async function refreshHealth() { + try { + healthIssues = await api("/api/system/health"); + } catch { + return; + } + const badge = $("#health-badge"); + badge.textContent = healthIssues.length; + badge.hidden = !healthIssues.length; + badge.classList.toggle("error", healthIssues.some((i) => i.level === "error")); + badge.classList.toggle("warn", !healthIssues.some((i) => i.level === "error")); + renderHealth(); +} + +async function loadSystem() { + refreshHealth(); + try { + logRows = await api("/api/system/logs"); + } catch (err) { + toast(err.message, true); + } + renderLogs(); +} + +function renderHealth() { + $("#health-list").innerHTML = healthIssues.length + ? healthIssues.map((i) => `
  • ${esc(t("health." + i.code, i))}
  • `).join("") + : `
  • ${t("system.healthy")}
  • `; +} + +function renderLogs() { + const tbody = $("#log-table tbody"); + if (!logRows.length) { + tbody.innerHTML = `${t("common.nothingHere")}`; + return; + } + const locale = lang === "de" ? "de-DE" : "en-GB"; + const LEVEL_STATE = { WARNING: "warn", ERROR: "failed", CRITICAL: "failed" }; + tbody.innerHTML = logRows + .map((r) => ` + ${esc(new Date(r.at).toLocaleString(locale))} + ${esc(r.level)} + ${esc(r.logger)} + ${esc(r.message)} + `) + .join(""); +} + // ---- request detail dialog ---- let detailRequest = null; @@ -1905,6 +1960,8 @@ function onLangChange() { renderRequests("missing"); renderRequests("imported"); renderHistory(); + renderHealth(); + renderLogs(); if (libraries.length) loadLibraries(); const editId = $("#lib-form").dataset.editId; editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit(); @@ -1920,6 +1977,7 @@ async function showVersion() { // the version alone rarely moves, so the stamp is what tells deploys apart $("#app-version").textContent = `v${v.version} · ${v.build || v.built_at}`; $("#app-version").title = `${v.built_at} UTC${v.build ? " · " + v.build : ""}`; + $("#system-version").textContent = `v${v.version}${v.build ? " · " + v.build : ""} · ${v.built_at} UTC`; } catch {} } diff --git a/static/i18n.js b/static/i18n.js index 14bb8dc..98eaf4a 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -47,6 +47,20 @@ const STRINGS = { "nav.import": "Import", "nav.settings": "Libraries", "nav.activity": "Aktivität", + "nav.system": "System", + "system.health": "Zustand", + "system.healthy": "Alles in Ordnung.", + "system.version": "Version", + "system.logs": "Letzte Log-Einträge", + "system.level": "Stufe", + "system.source": "Quelle", + "system.message": "Meldung", + "health.download.missing": "Der Download-Ordner {path} existiert nicht.", + "health.download.readonly": "wordarr kann im Download-Ordner {path} nicht schreiben, Importe schlagen fehl.", + "health.library.missing": "Library „{name}“: Der Ordner {path} existiert nicht.", + "health.library.readonly": "Library „{name}“: wordarr kann in {path} nicht schreiben.", + "health.hardcover.token": "Kein Hardcover-Token gesetzt (WORDARR_HARDCOVER_TOKEN), die Ebook-Suche fällt auf Google Books und Open Library zurück.", + "health.abs.incomplete": "Audiobookshelf ist nur halb eingerichtet: WORDARR_ABS_URL und WORDARR_ABS_TOKEN müssen beide gesetzt sein.", "nav.langToggle": "Switch to English", "history.time": "Zeit", "history.event": "Ereignis", @@ -295,6 +309,20 @@ const STRINGS = { "nav.import": "Import", "nav.settings": "Libraries", "nav.activity": "Activity", + "nav.system": "System", + "system.health": "Health", + "system.healthy": "All good.", + "system.version": "Version", + "system.logs": "Recent log entries", + "system.level": "Level", + "system.source": "Source", + "system.message": "Message", + "health.download.missing": "The download folder {path} does not exist.", + "health.download.readonly": "wordarr cannot write to the download folder {path}, imports will fail.", + "health.library.missing": "Library “{name}”: the folder {path} does not exist.", + "health.library.readonly": "Library “{name}”: wordarr cannot write to {path}.", + "health.hardcover.token": "No Hardcover token set (WORDARR_HARDCOVER_TOKEN), ebook search falls back to Google Books and Open Library.", + "health.abs.incomplete": "Audiobookshelf is only half set up: WORDARR_ABS_URL and WORDARR_ABS_TOKEN must both be set.", "nav.langToggle": "Auf Deutsch umschalten", "history.time": "Time", "history.event": "Event", diff --git a/static/index.html b/static/index.html index b92f1ca..0209978 100644 --- a/static/index.html +++ b/static/index.html @@ -22,6 +22,10 @@ + @@ -299,6 +303,27 @@ + +