feat(system): report health problems and keep recent log entries
This commit is contained in:
1 parent
b85bdcf5aa
commit
4441c5a24d
4 files changed
+110
-2
No files matched your search
@@ -1328,3 +1328,34 @@ def test_adopt_files_already_in_the_library(client):
|
|||||||
assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved
|
assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved
|
||||||
assert client.get(f"/api/import/library/{lib}").json()["items"] == []
|
assert client.get(f"/api/import/library/{lib}").json()["items"] == []
|
||||||
assert [h["event"] for h in client.get("/api/history").json()] == ["adopted"] * 2
|
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 import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import db, version
|
from . import db, logs, version
|
||||||
from .api import fs, history, imports, libraries, requests, search
|
from .api import fs, history, imports, libraries, requests, search, system
|
||||||
|
|
||||||
|
logs.install()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -22,6 +24,7 @@ app.include_router(requests.router)
|
|||||||
app.include_router(imports.router)
|
app.include_router(imports.router)
|
||||||
app.include_router(fs.router)
|
app.include_router(fs.router)
|
||||||
app.include_router(history.router)
|
app.include_router(history.router)
|
||||||
|
app.include_router(system.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
|
|||||||
Reference in new issue
Block a user