dashboard/app/journal.py

102 lines
3.1 KiB
Python

import asyncio
import re
from datetime import UTC, datetime
from typing import Any
CURSOR_RE = re.compile(r"^[A-Za-z0-9;:=+./_-]+$")
LEVELS = {"all": None, "warn": "warning", "err": "err"}
FIELD_RE = re.compile(r"^([A-Z_][A-Z0-9_]*)=")
def parse_export(text: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
cur: dict[str, Any] | None = None
last_key: str | None = None
for raw in text.splitlines():
if raw == "":
if cur is not None:
entries.append(cur)
cur, last_key = None, None
continue
m = FIELD_RE.match(raw)
if m:
if cur is None:
cur = {}
last_key = m.group(1)
if last_key is not None:
cur[last_key] = raw[m.end():]
elif cur is not None and last_key is not None:
cur[last_key] += "\n" + raw
if cur is not None:
entries.append(cur)
return entries
def format_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for e in entries:
ts = e.get("__REALTIME_TIMESTAMP")
if ts is None:
continue
stamp = ""
try:
dt = datetime.fromtimestamp(int(ts) / 1e6, tz=UTC).astimezone()
stamp = dt.strftime("%H:%M:%S")
except (ValueError, OSError, TypeError):
pass
try:
prio = int(e.get("PRIORITY", "6"))
except ValueError:
prio = 6
msg = e.get("MESSAGE", "").rstrip("\n")
out.append(
{
"stamp": stamp,
"prio": prio,
"ident": e.get("SYSLOG_IDENTIFIER") or e.get("_COMM") or e.get("_PID", "?"),
"msg": msg,
"cursor": e.get("__CURSOR", ""),
}
)
return out
async def _journalctl(argv: list[str]) -> str:
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode(errors="replace").strip() or "journalctl failed")
return out.decode(errors="replace")
async def tail(
cursor: str | None,
level: str,
unit: str | None,
search: str | None,
lines: int,
hide_sudo: bool = False,
) -> tuple[list[dict[str, Any]], str | None]:
fetch = lines * 2 if hide_sudo else lines
args = ["--no-pager", "-o", "export", "-n", str(min(max(fetch, 1), 500))]
lvl = LEVELS.get(level)
if lvl:
args += ["-p", lvl]
if unit and re.match(r"^[A-Za-z0-9@:_.\-+]+\.\w+$", unit):
args += ["-u", unit]
if search:
args += [search[:200]]
if cursor and CURSOR_RE.match(cursor):
args += ["--after-cursor", cursor]
text = await _journalctl(["sudo", "journalctl"] + args)
entries = parse_export(text)
if hide_sudo:
entries = [e for e in entries if e.get("SYSLOG_IDENTIFIER") != "sudo"]
entries = format_entries(entries)
last_cursor = entries[-1]["cursor"] if entries else None
return entries, last_cursor