Feature/system health #7
No files matched your search
+59
-1
@@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user