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")