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 @@
+
+
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) */