102 lines
3.1 KiB
Python
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
|