Feature/system health #7
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 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