logs.py 2.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
  1. """Log tailing for the admin log viewer.
  2. Reads the tail of a rotating log file backwards in blocks — never loads the whole file.
  3. The log source is an ENUM KEY resolved to a path server-side; the client can never name
  4. a file path (a `?path=` parameter here would be an arbitrary-file-read hole).
  5. Level classification is done here, server-side, so the ops-log format stays owned by the
  6. backend and the browser just colours by the returned `level`.
  7. """
  8. from __future__ import annotations
  9. import os
  10. from pathlib import Path
  11. MAX_LINES = 2000
  12. MAX_BYTES = 2_000_000
  13. # name -> function that returns the file path from the app config section.
  14. LOG_SOURCES = {
  15. "ops": lambda app_cfg: app_cfg.get("worker_log", "worker.log"),
  16. "app": lambda app_cfg: app_cfg.get("app_log", "app.log"),
  17. }
  18. def resolve_path(source: str, app_cfg) -> Path:
  19. if source not in LOG_SOURCES:
  20. raise KeyError(source)
  21. return Path(LOG_SOURCES[source](app_cfg)).resolve()
  22. def tail(path: Path, lines: int = 200, max_bytes: int = MAX_BYTES) -> tuple[list[str], bool]:
  23. """Return (last `lines` lines, truncated?). Reads backwards; bounded memory."""
  24. lines = max(1, min(lines, MAX_LINES))
  25. if not path.exists():
  26. return [], False
  27. with path.open("rb") as fh:
  28. fh.seek(0, os.SEEK_END)
  29. pos = fh.tell()
  30. data = b""
  31. while pos > 0 and data.count(b"\n") <= lines and len(data) < max_bytes:
  32. step = min(65536, pos)
  33. pos -= step
  34. fh.seek(pos)
  35. data = fh.read(step) + data
  36. text = data.decode("utf-8", errors="replace").splitlines()
  37. return text[-lines:], pos > 0
  38. def classify(source: str, line: str) -> str:
  39. """Map a log line to a severity for colouring."""
  40. if source == "ops":
  41. return "error" if ("result=FAILED" in line or "error=" in line) else "info"
  42. if " ERROR " in line or " CRITICAL " in line:
  43. return "error"
  44. if " WARNING " in line:
  45. return "warn"
  46. return "info"
  47. def read_log(source: str, app_cfg, lines: int = 200, query: str | None = None) -> dict:
  48. path = resolve_path(source, app_cfg)
  49. raw, truncated = tail(path, lines)
  50. if query:
  51. q = query.casefold()
  52. raw = [ln for ln in raw if q in ln.casefold()]
  53. return {
  54. "source": source,
  55. "truncated": truncated,
  56. "lines": [{"text": ln, "level": classify(source, ln)} for ln in raw],
  57. }