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