165 lines
5.4 KiB
Python
165 lines
5.4 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]]:
|
|
"""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
|