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]]: """Parse `journalctl -o export` output into entry dicts. The export format is `KEY=value` lines separated by blank lines; a line that does not start with an uppercase key is a continuation of the previous value (joined with newlines). Note the raw output can contain NUL bytes, which callers must tolerate. Args: text: raw `journalctl -o export` output. Returns: One dict per entry, key to value (multi-line values preserved). """ 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]]: """Reduce raw export entries to the fields the journal tab renders. Entries without a realtime timestamp are dropped. The local time is formatted as HH:MM:SS (invalid timestamps render as an empty string), PRIORITY defaults to 6 (info), and the identifier falls back SYSLOG_IDENTIFIER -> _COMM -> _PID. Args: entries: dicts from parse_export. Returns: One row per kept entry with stamp, prio, ident, msg, cursor. """ 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: """Run a journalctl subprocess and return its stdout. Args: argv: full command, e.g. ["sudo", "journalctl", "-n", "100"]. Returns: The decoded stdout. Raises: RuntimeError: if journalctl exits non-zero; the message is its stderr (or "journalctl failed" when stderr is empty). """ 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 a recent journal page, newest entries last. Runs `sudo journalctl -o export` with the requested filters. A non-empty cursor is validated against CURSOR_RE before being passed as --after-cursor (invalid cursors are silently ignored); level maps through LEVELS, the unit name is regex-checked, and the free-text search is truncated to 200 chars. When hide_sudo is set, twice as many lines are fetched (journalctl cannot express negated matches) and sudo's own entries are filtered out in Python afterwards. Args: cursor: opaque journal cursor to continue after, or None. level: one of "all" / "warn" / "err". unit: systemd unit to filter on, or None. search: free-text match, or None. lines: target number of entries. hide_sudo: drop entries logged by sudo itself. Returns: (formatted rows from format_entries, cursor of the newest row or None when nothing was returned). Raises: RuntimeError: if journalctl fails (see _journalctl). """ 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