Feature/system health #7

Merged
admin merged 2 commits from feature/system-health into main 2026-10-25 20:09:34 +01:00
8 changed files with 229 additions and 3 deletions

No files matched your search

+59 -1
View File
@@ -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) => `<li class="${i.level}">${esc(t("health." + i.code, i))}</li>`).join("")
: `<li class="ok">${t("system.healthy")}</li>`;
}
function renderLogs() {
const tbody = $("#log-table tbody");
if (!logRows.length) {
tbody.innerHTML = `<tr><td colspan="4" class="muted">${t("common.nothingHere")}</td></tr>`;
return;
}
const locale = lang === "de" ? "de-DE" : "en-GB";
const LEVEL_STATE = { WARNING: "warn", ERROR: "failed", CRITICAL: "failed" };
tbody.innerHTML = logRows
.map((r) => `<tr>
<td class="state">${esc(new Date(r.at).toLocaleString(locale))}</td>
<td class="state ${LEVEL_STATE[r.level] || ""}">${esc(r.level)}</td>
<td class="muted mono">${esc(r.logger)}</td>
<td class="mono">${esc(r.message)}</td>
</tr>`)
.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 {}
}
+28
View File
@@ -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",
+25
View File
@@ -22,6 +22,10 @@
<button data-view="import" data-i18n="nav.import"></button>
<button data-view="activity" data-i18n="nav.activity"></button>
<button data-view="settings" data-i18n="nav.settings"></button>
<button data-view="system">
<span data-i18n="nav.system"></span>
<span id="health-badge" class="badge" hidden></span>
</button>
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
</nav>
</header>
@@ -299,6 +303,27 @@
</div>
</section>
<section id="view-system" hidden>
<h3 data-i18n="system.health"></h3>
<ul id="health-list"></ul>
<h3 data-i18n="system.version"></h3>
<p id="system-version" class="mono"></p>
<h3 data-i18n="system.logs"></h3>
<div class="table-scroll">
<table id="log-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="system.level"></th>
<th data-i18n="system.source"></th>
<th data-i18n="system.message"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-settings" hidden>
<h3 data-i18n="lib.heading"></h3>
<div class="table-scroll">
+7
View File
@@ -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) */
+31
View File
@@ -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"
+44
View File
@@ -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))
+30
View File
@@ -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)
+5 -2
View File
@@ -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")