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) => `
diff --git a/static/style.css b/static/style.css
index d799ce8..ee705af 100644
--- a/static/style.css
+++ b/static/style.css
@@ -74,6 +74,13 @@ td.state { white-space: nowrap; font-size: 0.85rem; }
td.state.done { color: var(--ok); }
td.state.failed { color: #e08585; }
td.state.running { color: var(--muted); }
+td.state.warn { color: var(--warn); }
+.badge.warn { background: var(--warn); color: #000; }
+.badge.error { background: var(--danger); }
+#health-list { list-style: none; margin: 0.5rem 0 1rem; }
+#health-list li { background: var(--panel); border-left: 3px solid var(--warn); border-radius: 4px; padding: 0.5rem 0.8rem; margin: 0.3rem 0; }
+#health-list li.error { border-left-color: var(--danger); }
+#health-list li.ok { border-left-color: var(--ok); }
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
/* view modes (Missing/Importiert) */
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")