Refacor: Use JSON outputs of command line utils as much as possible and

generalize sysfs and subprocess code
This commit is contained in:
Johannes Schriewer 2026-08-31 00:47:02 +02:00
parent 719690203f
commit 4f8fc0f361
19 changed files with 6486 additions and 391 deletions

View file

@ -46,13 +46,20 @@ agent's own shell command line and kills the session.
- `app/collect/*` — collectors (cpu/mem/gpu/disks/procs/net/power) read
psutil + sysfs; `app/sampling.py` runs them every `DASH_SAMPLE_INTERVAL`
(default 2 s) into an in-memory ring buffer (`app/state.py`).
- `app/utils/` — shared helpers: `subprocess.py` (`run`/`run_async` return
`(rc, stdout, stderr)` and never raise — missing binary or timeout is
rc -1 with the reason in stderr; `run_json*` variants parse stdout as
JSON), `sysfs.py` (`read_str`/`read_int`/`read_float`), `gpu.py`
(`shorten`), `window.py` (the `/api/history` windowing), `systemd.py`
(unit listing/detail/actions; `list-units`/`list-unit-files` use
`--output=json`, needs systemd ≥ ~246, `show` has no JSON).
- `app/routers/*` — each tab endpoint is an idempotent GET returning an htmx
HTML fragment; templates live in `templates/` and self-poll via
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`.
- `templates/*.html` auto-reload on file change — no restart needed for
template-only edits. Python changes require a restart.
- `app/systemd/units.py` — systemd unit listing/detail/actions;
`app/journal.py``journalctl -o export` parser with cursors.
- `app/journal.py` — `journalctl -o json` parser (one JSON object per line)
with cursors.
- `app/plugins/``base.Plugin` (optional `open`/`close` lifecycle hooks run
from app lifespan) + llamacpp plugin (talks to a router-mode `llama-server`
on port 8080) + sleep plugin (lists block-mode `systemd-inhibit` locks;
@ -60,7 +67,7 @@ agent's own shell command line and kills the session.
while the UI switch is on, reaps stale locks by `who` marker on startup)
+ lact plugin (shells out to `lact cli`: per-GPU profile dropdown with
set/reload, active profile polled every 5 s, GPU names shortened with
`app/collect/gpu.py:shorten` like the overview card).
`app/utils/gpu.py:shorten` like the overview card).
## Conventions
@ -79,9 +86,15 @@ agent's own shell command line and kills the session.
- Jinja autoescape renders `↓` as literal text — use literal unicode
(e.g. `↓`) in templates.
- `journalctl -o export` output contains NUL bytes (grep treats it as
binary); journalctl rejects negated matches (`!`/`!=`) — filter entries in
- `journalctl` is queried with `-o json` on purpose: the old `-o export`
output contains NUL bytes (grep treats it as binary) and multi-line
values need continuation-line handling, while JSON escapes both.
journalctl still rejects negated matches (`!`/`!=`) — filter entries in
Python instead.
- `nvidia-smi --format=json` keys are underscored and **values are
strings**; `lspci` has no JSON (use the locale-stable `-mm`
double-quoted format); `systemctl show`, `systemctl is-system-running`,
`iw`, and `lact cli` have no JSON output at all.
- psutil gotchas: there is no `psutil.AF_INET` (use `socket`);
`net_if_addrs()` / `net_if_stats()` take no arguments;
`sensors_battery().power_plugged` can be `None` — use

View file

@ -3,27 +3,12 @@ import glob
import psutil
from app.sample import Sample
from app.utils import sysfs
_temp_path: str | None = None
_temp_checked = False
def _read(path: str) -> str | None:
"""Read a sysfs file, returning its stripped contents.
Args:
path: path under /sys.
Returns:
The file contents, or None if it cannot be read.
"""
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def _find_temp_path() -> str | None:
"""Find the sysfs file reporting CPU temperature, in millidegrees.
@ -36,13 +21,13 @@ def _find_temp_path() -> str | None:
The sysfs file to read, or None if no suitable sensor exists.
"""
for hwmon in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
name = (_read(f"{hwmon}/name") or "").lower()
name = (sysfs.read_str(f"{hwmon}/name") or "").lower()
if name in ("k10temp", "coretemp", "cpu_thermal"):
for t in sorted(glob.glob(f"{hwmon}/temp*_input")):
return t
return None
for zone in sorted(glob.glob("/sys/class/thermal/thermal_zone*")):
if (_read(f"{zone}/type") or "").lower() == "acpitz":
if (sysfs.read_str(f"{zone}/type") or "").lower() == "acpitz":
return f"{zone}/temp"
return None
@ -62,14 +47,10 @@ def temp() -> float | None:
_temp_path = _find_temp_path()
if _temp_path is None:
return None
v = _read(_temp_path)
if not v:
v = sysfs.read_float(_temp_path)
if v is None:
return None
try:
n = float(v)
except ValueError:
return None
return round(n / 1000.0, 1)
return round(v / 1000.0, 1)
def prime() -> None:

View file

@ -1,63 +1,24 @@
import glob
import re
import shutil
import subprocess
from typing import Any, cast
from app.sample import Sample
from app.utils import sysfs
from app.utils.gpu import shorten
from app.utils.subprocess import run, run_json
_name_cache: str | None = None
def _read(path: str) -> str | None:
"""Read a sysfs file, returning its stripped contents.
Args:
path: path under /sys.
Returns:
The file contents, or None if it cannot be read.
"""
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def shorten(name: str) -> str:
"""Shorten a raw GPU device name (lspci / lact) for display.
Strips a trailing "(rev ...)" marker, then reformats by bracket
group: a name like "Renoir [Radeon Vega Series / ...]" becomes
"Renoir (Radeon Vega Series)"; a name with two or more groups (typical
for unbound PCI IDs, e.g. "[1002] Device [1586]") becomes
"first-group middle-text (last-group)"; anything else is truncated to
50 characters.
Args:
name: raw device name from lspci or lact.
Returns:
A display-friendly name.
"""
name = re.sub(r"\s*\(rev.*\)$", "", name).strip()
groups = re.findall(r"\[([^\]]+)\]", name)
if len(groups) >= 2:
brand = groups[0]
series = groups[-1].split(" / ")[0]
model = name.split("]", 1)[1].split("[", 1)[0].strip()
return f"{brand} {model} ({series})".strip()
if len(groups) == 1:
series = groups[0].split(" / ")[0]
model = name.split("[", 1)[0].strip()
return f"{model} ({series})".strip()
return name[:50]
_LSPCI_QUOTED = re.compile(r'"([^"]*)"')
def _gpu_name() -> str:
"""Resolve the display GPU name, cached for the process lifetime.
Runs `lspci` once and takes the first VGA / 3D-controller device name,
Runs `lspci -mm` once (stable machine-readable format, one line per
device with double-quoted fields: slot, class, vendor, device, ...)
and takes the vendor + device of the first VGA / 3D-controller line,
shortened with shorten(). Falls back to "GPU" if lspci is missing or
no matching device line is found.
@ -68,16 +29,13 @@ def _gpu_name() -> str:
if _name_cache is None:
_name_cache = "GPU"
if shutil.which("lspci"):
try:
out = subprocess.run(
["lspci"], capture_output=True, text=True, timeout=5, check=False
).stdout
rc, out, _err = run(["lspci", "-mm"], timeout=5)
if rc == 0:
for line in out.splitlines():
if "VGA" in line or "3D controller" in line:
_name_cache = shorten(line.split(":", 2)[-1].strip())
f = _LSPCI_QUOTED.findall(line)
if len(f) >= 3 and ("VGA" in f[0] or "3D controller" in f[0]):
_name_cache = shorten(f"{f[1]} {f[2]}")
break
except (OSError, subprocess.SubprocessError):
pass
return _name_cache
@ -106,20 +64,14 @@ def _amd(s: Sample) -> bool:
temps: list[float] = []
for busy_path in devices:
dev = busy_path.rsplit("/", 1)[0]
try:
busy_sum += int(_read(busy_path) or 0)
count += 1
except ValueError:
continue
vram_used += int(_read(f"{dev}/mem_info_vram_used") or 0)
vram_total += int(_read(f"{dev}/mem_info_vram_total") or 0)
busy_sum += sysfs.read_int(busy_path) or 0
count += 1
vram_used += sysfs.read_int(f"{dev}/mem_info_vram_used") or 0
vram_total += sysfs.read_int(f"{dev}/mem_info_vram_total") or 0
for hwmon in glob.glob(f"{dev}/hwmon/hwmon*"):
t = _read(f"{hwmon}/temp1_input")
if t:
try:
temps.append(int(t) / 1000.0)
except ValueError:
pass
t = sysfs.read_int(f"{hwmon}/temp1_input")
if t is not None:
temps.append(t / 1000.0)
if count == 0:
return False
s.gpu = round(busy_sum / count, 1)
@ -134,9 +86,11 @@ def _amd(s: Sample) -> bool:
def _nvidia(s: Sample) -> bool:
"""Fill GPU fields by querying nvidia-smi.
Runs `nvidia-smi --query-gpu=...` (5 s timeout) and parses the
CSV: busy percent averaged across GPUs, VRAM summed (MiB converted to
bytes), temperature the hottest GPU, name from the first line.
Runs `nvidia-smi --query-gpu=... --format=json` (5 s timeout) and
parses the JSON array (keys are underscored, values strings): busy
percent averaged across GPUs, VRAM summed (MiB converted to bytes),
temperature the hottest GPU, name from the first GPU. JSON keeps
names with commas intact, which the old CSV format split on.
Args:
s: sample to fill.
@ -146,38 +100,35 @@ def _nvidia(s: Sample) -> bool:
"""
if not shutil.which("nvidia-smi"):
return False
try:
out = subprocess.run(
[
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=5,
check=True,
).stdout
except (OSError, subprocess.SubprocessError):
return False
lines = [l for l in out.splitlines() if l.strip()]
if not lines:
data, _err = run_json(
[
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name",
"--format=json",
],
timeout=5,
)
if not isinstance(data, list) or not data:
return False
rows = cast("list[dict[str, Any]]", data)
busy = used = total = 0
temp = 0
for line in lines:
parts = [p.strip() for p in line.split(",")]
count = 0
for e in rows:
try:
busy += int(parts[0])
used += int(parts[1])
total += int(parts[2])
temp = max(temp, int(parts[3]))
except ValueError:
busy += int(e["utilization_gpu"])
used += int(e["memory_used"])
total += int(e["memory_total"])
temp = max(temp, int(e["temperature_gpu"]))
count += 1
except (ValueError, TypeError, KeyError):
continue
name = lines[0].split(",")[-1].strip()
if count == 0:
return False
name = str(rows[0].get("name") or "").strip() or "GPU"
vram_used = used * 1024 * 1024
vram_total = total * 1024 * 1024
s.gpu = round(busy / len(lines), 1)
s.gpu = round(busy / count, 1)
s.vram_used = vram_used
s.vram_total = vram_total
s.vram_pct = round(vram_used / vram_total * 100, 1) if vram_total else None

View file

@ -2,12 +2,13 @@ import glob
import re
import shutil
import socket
import subprocess
import time
from typing import Any
import psutil
from app.utils.subprocess import run
_wifi_cache: dict[str, tuple[float, str | None]] = {}
_WIFI_TTL = 15.0
_SSID_RE = re.compile(r"SSID:\s+(\S.*)")
@ -44,15 +45,11 @@ def _ssid(iface: str) -> str | None:
return hit[1]
ssid: str | None = None
if shutil.which("iw"):
try:
out = subprocess.run(
["iw", "dev", iface, "link"], capture_output=True, text=True, timeout=3, check=False
).stdout
rc, out, _err = run(["iw", "dev", iface, "link"], timeout=3)
if rc == 0:
m = _SSID_RE.search(out)
if m:
ssid = m.group(1).strip().strip('"') or None
except (OSError, subprocess.SubprocessError):
pass
_wifi_cache[iface] = (now, ssid)
return ssid

View file

@ -1,26 +1,11 @@
import glob
from app.sample import Sample
from app.utils import sysfs
_PS = "/sys/class/power_supply"
def _read(path: str) -> str | None:
"""Read a sysfs file, returning its stripped contents.
Args:
path: path under /sys.
Returns:
The file contents, or None if it cannot be read.
"""
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def _supplies() -> list[tuple[str, str]]:
"""List power supplies found under /sys/class/power_supply.
@ -30,7 +15,7 @@ def _supplies() -> list[tuple[str, str]]:
"""
out: list[tuple[str, str]] = []
for p in sorted(glob.glob(f"{_PS}/*")):
t = _read(f"{p}/type")
t = sysfs.read_str(f"{p}/type")
if t:
out.append((t.lower(), p))
return out
@ -51,22 +36,19 @@ def fill(s: Sample) -> None:
try:
supplies = _supplies()
for t, p in supplies:
if t == "battery" and _read(f"{p}/present") == "1":
cap = _read(f"{p}/capacity")
if t == "battery" and sysfs.read_str(f"{p}/present") == "1":
cap = sysfs.read_int(f"{p}/capacity")
if cap is not None:
try:
s.battery = int(cap)
except ValueError:
pass
s.battery_status = _read(f"{p}/status")
s.battery = cap
s.battery_status = sysfs.read_str(f"{p}/status")
break
for t, p in supplies:
if t == "mains" and _read(f"{p}/online") == "1":
if t == "mains" and sysfs.read_str(f"{p}/online") == "1":
s.ac_online = True
break
if s.ac_online is None:
for t, p in supplies:
if t == "usb" and _read(f"{p}/online") == "1":
if t == "usb" and sysfs.read_str(f"{p}/online") == "1":
s.ac_online = True
break
except OSError:

View file

@ -1,10 +1,11 @@
import shutil
import subprocess
import time
from typing import Any
from typing import Any, cast
import psutil
from app.utils.subprocess import run_json
_prev_io: dict[int, tuple[float, float, float]] = {}
_gpu_procs: dict[int, int] | None = None
_gpu_probe_t = 0.0
@ -13,9 +14,10 @@ _gpu_probe_t = 0.0
def _gpu_per_proc() -> dict[int, int]:
"""Map PID to GPU memory used (MiB) for NVIDIA compute processes.
Runs `nvidia-smi --query-compute-apps` at most once per 10 seconds
(the probe result is cached). Returns an empty mapping when nvidia-smi
is missing, which is the case on AMD machines.
Runs `nvidia-smi --query-compute-apps --format=json` at most once per
10 seconds (the probe result is cached). Returns an empty mapping
when nvidia-smi is missing or fails, which is the case on AMD
machines.
Returns:
A pid to used-memory-in-MiB mapping.
@ -27,27 +29,20 @@ def _gpu_per_proc() -> dict[int, int]:
return _gpu_procs
_gpu_probe_t = time.monotonic()
_gpu_procs = {}
try:
out = subprocess.run(
[
"nvidia-smi",
"--query-compute-apps=pid,used_memory",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=5,
check=False
).stdout
for line in out.splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) >= 2:
try:
_gpu_procs[int(parts[0])] = int(parts[1])
except ValueError:
continue
except (OSError, subprocess.SubprocessError):
pass
data, _err = run_json(
[
"nvidia-smi",
"--query-compute-apps=pid,used_memory",
"--format=json",
],
timeout=5,
)
if isinstance(data, list):
for e in cast("list[dict[str, Any]]", data):
try:
_gpu_procs[int(e["pid"])] = int(e["used_memory"])
except (ValueError, TypeError, KeyError):
continue
return _gpu_procs

View file

@ -1,47 +1,40 @@
import asyncio
import json
import re
from datetime import UTC, datetime
from typing import Any
from typing import Any, cast
from app.utils.subprocess import run_async
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.
def parse_lines(text: str) -> list[dict[str, Any]]:
"""Parse `journalctl -o json` 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.
Each non-empty line is one JSON object. Multi-line messages are
embedded as \\n escapes and control characters (e.g. NUL) are
JSON-escaped, so no continuation-line handling is needed the
former -o export format required both.
Args:
text: raw `journalctl -o export` output.
text: raw `journalctl -o json` output.
Returns:
One dict per entry, key to value (multi-line values preserved).
One dict per entry; lines that are not valid JSON objects are
skipped.
"""
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
for line in text.splitlines():
line = line.strip()
if not line:
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)
try:
e = json.loads(line)
except ValueError:
continue
if isinstance(e, dict):
entries.append(cast("dict[str, Any]", e))
return entries
@ -54,7 +47,7 @@ def format_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
SYSLOG_IDENTIFIER -> _COMM -> _PID.
Args:
entries: dicts from parse_export.
entries: dicts from parse_lines.
Returns:
One row per kept entry with stamp, prio, ident, msg, cursor.
@ -91,24 +84,20 @@ 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"].
argv: full command, e.g. ["sudo", "journalctl", "-o", "json", "-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).
RuntimeError: if journalctl exits non-zero (or cannot be
spawned); 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")
rc, out, err = await run_async(argv)
if rc != 0:
raise RuntimeError(err.strip() or "journalctl failed")
return out
async def tail(
@ -121,7 +110,7 @@ async def tail(
) -> 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
Runs `sudo journalctl -o json` 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
@ -145,7 +134,7 @@ async def tail(
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))]
args = ["--no-pager", "-o", "json", "-n", str(min(max(fetch, 1), 500))]
lvl = LEVELS.get(level)
if lvl:
args += ["-p", lvl]
@ -157,7 +146,7 @@ async def tail(
args += ["--after-cursor", cursor]
text = await _journalctl(["sudo", "journalctl"] + args)
entries = parse_export(text)
entries = parse_lines(text)
if hide_sudo:
entries = [e for e in entries if e.get("SYSLOG_IDENTIFIER") != "sudo"]
entries = format_entries(entries)

View file

@ -6,9 +6,10 @@ from typing import Annotated, Any
from fastapi import APIRouter, Form
from fastapi.responses import HTMLResponse
from app.collect.gpu import shorten
from app.plugins.base import Plugin
from app.render import render
from app.utils.gpu import shorten
from app.utils.subprocess import run_async
router = APIRouter(prefix="/api/plugins/lact", tags=["plugins"])
@ -34,27 +35,10 @@ async def _run(args: list[str], timeout: float) -> tuple[str, str]:
Returns:
(stdout, "") on success, else ("", error description).
"""
try:
proc = await asyncio.create_subprocess_exec(
"lact", "cli", *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return "", "lact not found in PATH"
except OSError as e:
return "", str(e)[:200]
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
return "", "lact timed out"
if proc.returncode != 0:
return "", (err.decode(errors="replace").strip() or f"lact failed (rc={proc.returncode})")[:200]
return out.decode(errors="replace"), ""
rc, out, err = await run_async(["lact", "cli", *args], timeout=timeout)
if rc != 0:
return "", (err.strip() or f"lact failed (rc={rc})")[:200]
return out, ""
def _parse_gpus(out: str) -> list[dict[str, str]]:
@ -62,7 +46,7 @@ def _parse_gpus(out: str) -> list[dict[str, str]]:
Each line looks like "0: <device> (Renoir [Radeon Vega Series / ...])
[Integrated]"; the parenthesised name is shortened with
app.collect.gpu.shorten, the trailing bracket is the GPU type.
app.utils.gpu.shorten, the trailing bracket is the GPU type.
Non-matching lines are skipped.
Args:

View file

@ -1,5 +1,4 @@
import asyncio
import json
import os
import signal
from typing import Annotated, Any, cast
@ -9,6 +8,7 @@ from fastapi.responses import HTMLResponse
from app.plugins.base import Plugin
from app.render import render
from app.utils.subprocess import run_json_async
router = APIRouter(prefix="/api/plugins/sleep", tags=["plugins"])
@ -31,28 +31,9 @@ async def _list() -> tuple[list[dict[str, Any]], str]:
Returns:
(lock entries, "") on success, else ([], error description).
"""
try:
proc = await asyncio.create_subprocess_exec(
"systemd-inhibit", "--json=short", "--list",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as e:
return [], str(e)[:200]
try:
out, err = await asyncio.wait_for(proc.communicate(), 5)
except TimeoutError:
try:
_ = proc.kill()
except ProcessLookupError:
pass
return [], "systemd-inhibit timed out"
if proc.returncode != 0:
return [], (err.decode(errors="replace").strip() or f"systemd-inhibit failed (rc={proc.returncode})")[:200]
try:
data = json.loads(out.decode(errors="replace"))
except ValueError:
return [], "could not parse systemd-inhibit output"
data, err = await run_json_async(["systemd-inhibit", "--json=short", "--list"], timeout=5)
if err:
return [], err[:200]
if not isinstance(data, list):
return [], "unexpected systemd-inhibit output"
items: list[dict[str, Any]] = [e for e in cast("list[Any]", data) if isinstance(e, dict)]

View file

@ -1,8 +1,6 @@
import asyncio
import math
import socket
import time
from dataclasses import fields
from typing import Any
import psutil
@ -13,53 +11,10 @@ from app.collect import net as net_col
from app.config import get_settings
from app.render import render, uptime_str
from app.sample import Sample
from app.utils.window import window
router = APIRouter(prefix="/api", tags=["overview"])
RowAgg = dict[str, float | int | None]
def _window(snap: list[Sample], max_points: int) -> list[tuple[float, dict[str, RowAgg]]]:
"""Window-average a sample list down to at most `max_points` points.
The samples are split into consecutive chunks of ceil(n / max_points)
and each numeric Sample field is reduced to {avg, min, max} per chunk;
whole-number fields (byte counts) stay ints, fractional fields are
rounded to 0.1. Each point is stamped with the timestamp of the last
sample in its chunk.
Args:
snap: samples oldest first (HistoryStore.snapshot).
max_points: maximum number of points to emit.
Returns:
(timestamp, field aggregations) pairs, oldest first.
"""
n = len(snap)
w = max(1, math.ceil(n / max_points))
out: list[tuple[float, dict[str, RowAgg]]] = []
for start in range(0, n, w):
chunk = snap[start : start + w]
vals: dict[str, list[int | float]] = {}
for sample in chunk:
for f in fields(sample):
if f.name == "ts":
continue
v = getattr(sample, f.name)
if isinstance(v, (int, float)) and not isinstance(v, bool):
vals.setdefault(f.name, []).append(v)
row: dict[str, RowAgg] = {}
for k, lst in vals.items():
ints = all(isinstance(v, int) for v in lst)
avg = sum(lst) / len(lst)
row[k] = {
"avg": round(avg) if ints else round(avg, 1),
"min": min(lst) if ints else round(min(lst), 1),
"max": max(lst) if ints else round(max(lst), 1),
}
out.append((chunk[-1].ts, row))
return out
@router.get("/overview")
async def overview(request: Request):
@ -114,11 +69,11 @@ async def overview(request: Request):
async def history(request: Request):
"""Serve the ring buffer as chart data (JSON).
The buffer is window-averaged via _window() down to at most
`chart_max_points` points. Every key seen in any window gets avg/min/
max arrays, and each array is padded with None for windows that lack
the key (e.g. the GPU fields before a GPU is detected) so the arrays
stay aligned with the ts array the charts rely on that.
The buffer is window-averaged via app.utils.window.window() down to
at most `chart_max_points` points. Every key seen in any window gets
avg/min/max arrays, and each array is padded with None for windows
that lack the key (e.g. the GPU fields before a GPU is detected) so
the arrays stay aligned with the ts array the charts rely on that.
Args:
request: FastAPI request (app.state.store).
@ -126,7 +81,7 @@ async def history(request: Request):
Returns:
JSON with ts (unix seconds) and series: key to {avg, min, max}.
"""
snap = _window(request.app.state.store.snapshot(), get_settings().chart_max_points)
snap = window(request.app.state.store.snapshot(), get_settings().chart_max_points)
ts = [round(t, 1) for t, _ in snap]
keys: set[str] = set()
for _, row in snap:

View file

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from app import journal
from app.render import render
from app.systemd import units
from app.utils import systemd
router = APIRouter(prefix="/api/services", tags=["services"])
@ -41,7 +41,7 @@ def _rank(u: dict[str, Any], key: str) -> int:
returns 0 here.
Args:
u: unit row from units.unit_list().
u: unit row from systemd.unit_list().
key: "state" or "enabled".
Returns:
@ -74,7 +74,7 @@ async def _list_fragment(q: str, sort: str = "name", order: str = "asc", error:
sort = "name"
if order not in ("asc", "desc"):
order = "asc"
unit_list = await units.unit_list()
unit_list = await systemd.unit_list()
if q:
ql = q.lower()
unit_list = [
@ -85,7 +85,7 @@ async def _list_fragment(q: str, sort: str = "name", order: str = "asc", error:
unit_list.sort(key=lambda u: u["name"], reverse=reverse)
else:
unit_list.sort(key=lambda u: (_rank(u, sort), u["name"]), reverse=reverse)
state = await units.system_state()
state = await systemd.system_state()
return render(
"services.html",
units=unit_list,
@ -116,7 +116,7 @@ async def services(q: str = "", sort: str = "name", order: str = "asc"):
async def service_detail(unit: str):
"""Render the detail fragment for one service.
Shows the unit's properties (via units.unit_detail) plus its 15 most
Shows the unit's properties (via systemd.unit_detail) plus its 15 most
recent journal lines. A detail error suppresses the journal fetch and
is rendered as a banner.
@ -130,7 +130,7 @@ async def service_detail(unit: str):
props: dict[str, str] = {}
log: list[dict[str, str]] = []
try:
props = await units.unit_detail(unit)
props = await systemd.unit_detail(unit)
except (ValueError, RuntimeError) as e:
error = str(e)[:300]
if not error:
@ -157,7 +157,7 @@ async def service_action(
Args:
unit: unit name.
action: one of units.ACTIONS.
action: one of systemd.ACTIONS.
q: search filter to keep.
sort: column to sort by.
order: "asc" or "desc".
@ -167,7 +167,7 @@ async def service_action(
"""
error = None
try:
_ = await units.unit_action(unit, action)
_ = await systemd.unit_action(unit, action)
except ValueError as e:
error = str(e)
except RuntimeError as e:

View file

@ -1,3 +0,0 @@
from app.systemd import units
__all__ = ["units"]

3
app/utils/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from app.utils import gpu, subprocess, systemd, sysfs, window
__all__ = ["gpu", "subprocess", "systemd", "sysfs", "window"]

31
app/utils/gpu.py Normal file
View file

@ -0,0 +1,31 @@
import re
def shorten(name: str) -> str:
"""Shorten a raw GPU device name (lspci / lact) for display.
Strips a trailing "(rev ...)" marker, then reformats by bracket
group: a name like "Renoir [Radeon Vega Series / ...]" becomes
"Renoir (Radeon Vega Series)"; a name with two or more groups (typical
for unbound PCI IDs, e.g. "[1002] Device [1586]") becomes
"first-group middle-text (last-group)"; anything else is truncated to
50 characters.
Args:
name: raw device name from lspci or lact.
Returns:
A display-friendly name.
"""
name = re.sub(r"\s*\(rev.*\)$", "", name).strip()
groups = re.findall(r"\[([^\]]+)\]", name)
if len(groups) >= 2:
brand = groups[0]
series = groups[-1].split(" / ")[0]
model = name.split("]", 1)[1].split("[", 1)[0].strip()
return f"{brand} {model} ({series})".strip()
if len(groups) == 1:
series = groups[0].split(" / ")[0]
model = name.split("[", 1)[0].strip()
return f"{model} ({series})".strip()
return name[:50]

104
app/utils/subprocess.py Normal file
View file

@ -0,0 +1,104 @@
import asyncio
import json
import subprocess
from collections.abc import Sequence
from typing import Any
def run(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[int, str, str]:
"""Run a command synchronously and capture its output.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(returncode, stdout, stderr), both decoded. Spawn failures
(missing binary, other OSError) and timeouts are reported as
returncode -1 with the reason in stderr instead of raising.
"""
try:
proc = subprocess.run(cmd, capture_output=True, timeout=timeout)
except FileNotFoundError:
return -1, "", f"{cmd[0]} not found in PATH"
except subprocess.TimeoutExpired:
return -1, "", f"{cmd[0]} timed out"
except (OSError, subprocess.SubprocessError) as e:
return -1, "", str(e)[:200]
return proc.returncode, proc.stdout.decode(errors="replace"), proc.stderr.decode(errors="replace")
async def run_async(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[int, str, str]:
"""Run a command asynchronously and capture its output.
The child is killed when the timeout expires.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(returncode, stdout, stderr), both decoded. Spawn failures
(missing binary, other OSError) and timeouts are reported as
returncode -1 with the reason in stderr instead of raising.
"""
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return -1, "", f"{cmd[0]} not found in PATH"
except OSError as e:
return -1, "", str(e)[:200]
try:
if timeout is None:
out, err = await proc.communicate()
else:
out, err = await asyncio.wait_for(proc.communicate(), timeout)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
return -1, "", f"{cmd[0]} timed out"
return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace")
def run_json(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[Any, str]:
"""Run a command synchronously and parse its stdout as JSON.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(parsed JSON, "") on success, else (None, error description).
"""
rc, out, err = run(cmd, timeout=timeout)
if rc != 0:
return None, err or f"{cmd[0]} failed (rc={rc})"
try:
return json.loads(out), ""
except ValueError:
return None, f"{cmd[0]} returned invalid JSON"
async def run_json_async(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[Any, str]:
"""Run a command asynchronously and parse its stdout as JSON.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(parsed JSON, "") on success, else (None, error description).
"""
rc, out, err = await run_async(cmd, timeout=timeout)
if rc != 0:
return None, err or f"{cmd[0]} failed (rc={rc})"
try:
return json.loads(out), ""
except ValueError:
return None, f"{cmd[0]} returned invalid JSON"

52
app/utils/sysfs.py Normal file
View file

@ -0,0 +1,52 @@
def read_str(path: str) -> str | None:
"""Read a sysfs file, returning its stripped contents.
Args:
path: path under /sys.
Returns:
The file contents, or None if it cannot be read.
"""
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def read_int(path: str) -> int | None:
"""Read a sysfs file as an integer.
Args:
path: path under /sys.
Returns:
The parsed value, or None if the file cannot be read or does
not contain an integer.
"""
v = read_str(path)
if v is None:
return None
try:
return int(v)
except ValueError:
return None
def read_float(path: str) -> float | None:
"""Read a sysfs file as a float.
Args:
path: path under /sys.
Returns:
The parsed value, or None if the file cannot be read or does
not contain a number.
"""
v = read_str(path)
if v is None:
return None
try:
return float(v)
except ValueError:
return None

View file

@ -1,6 +1,9 @@
import asyncio
import json
import re
import time
from typing import Any
from app.utils.subprocess import run_async
UNIT_RE = re.compile(r"^[A-Za-z0-9@:_.\-+]+\.(service|socket|timer|target|path|slice)$")
ACTIONS = ("start", "stop", "restart", "enable", "disable")
@ -15,25 +18,6 @@ _DETAIL_PROPS = (
)
async def _run(cmd: list[str]) -> tuple[int, str, str]:
"""Run a command, capturing stdout and stderr.
Args:
cmd: program and arguments.
Returns:
(returncode, stdout, stderr), all decoded; a missing returncode
(should not happen) is reported as 0.
"""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace")
async def _systemctl(*args: str, privileged: bool = False) -> str:
"""Run a systemctl command and return its stdout.
@ -47,11 +31,12 @@ async def _systemctl(*args: str, privileged: bool = False) -> str:
The decoded stdout.
Raises:
RuntimeError: if systemctl exits non-zero; the message is its
stderr (or "systemctl <args> failed" when stderr is empty).
RuntimeError: if systemctl exits non-zero (or cannot be spawned);
the message is its stderr (or "systemctl <args> failed" when
stderr is empty).
"""
cmd = (["sudo", "systemctl", *args] if privileged else ["systemctl", *args])
rc, out, err = await _run(cmd)
rc, out, err = await run_async(cmd)
if rc == 0:
return out
raise RuntimeError(err.strip() or f"systemctl {' '.join(args)} failed")
@ -60,9 +45,9 @@ async def _systemctl(*args: str, privileged: bool = False) -> str:
async def _enabled_map(force: bool = False) -> dict[str, str]:
"""Map unit name to enabled-state (enabled, disabled, static, ...).
The result of `systemctl list-unit-files --type=service` is cached
module-wide for 30 s so fast polls don't re-run it; unit_action()
invalidates the cache after enable/disable.
The result of `systemctl list-unit-files --type=service --output=json`
is cached module-wide for 30 s so fast polls don't re-run it;
unit_action() invalidates the cache after enable/disable.
Args:
force: bypass the cache and re-query.
@ -75,13 +60,9 @@ async def _enabled_map(force: bool = False) -> dict[str, str]:
now = time.monotonic()
if not force and _enabled_cache is not None and now - _enabled_cache_at < _ENABLED_TTL:
return _enabled_cache
files = await _systemctl("list-unit-files", "--type=service", "--no-legend", "--plain")
m: dict[str, str] = {}
for line in files.splitlines():
parts = line.split(None, 2)
if len(parts) < 2:
continue
m[parts[0]] = parts[1].strip()
out = await _systemctl("list-unit-files", "--type=service", "--output=json")
rows: list[dict[str, Any]] = json.loads(out)
m: dict[str, str] = {e["unit_file"]: e["state"] for e in rows}
_enabled_cache = m
_enabled_cache_at = now
return m
@ -90,32 +71,25 @@ async def _enabled_map(force: bool = False) -> dict[str, str]:
async def unit_list() -> list[dict[str, str]]:
"""List all service units with their runtime and enabled state.
Merges `systemctl list-units --all` (currently known units) with the
enabled-state map, so units that are configured but not active still
appear (with placeholder load/active/sub values).
Merges `systemctl list-units --all --output=json` (currently known
units) with the enabled-state map, so units that are configured but
not active still appear (with placeholder load/active/sub values).
Returns:
One row per unit (name, load, active, sub, desc, enabled),
sorted by unit name.
"""
out = await _systemctl(
"list-units", "--type=service", "--all", "--no-legend", "--plain"
)
out = await _systemctl("list-units", "--type=service", "--all", "--output=json")
enabled = await _enabled_map()
units: dict[str, dict[str, str]] = {}
for line in out.splitlines():
parts = line.split(None, 4)
if len(parts) < 4:
continue
name, load, active, sub = parts[0], parts[1], parts[2], parts[3]
desc = parts[4] if len(parts) > 4 else ""
units[name] = {
"name": name,
"load": load,
"active": active,
"sub": sub,
"desc": desc,
"enabled": enabled.get(name, ""),
for e in json.loads(out):
units[e["unit"]] = {
"name": e["unit"],
"load": e["load"],
"active": e["active"],
"sub": e["sub"],
"desc": e.get("description", ""),
"enabled": enabled.get(e["unit"], ""),
}
for name, state in enabled.items():
if name not in units:

48
app/utils/window.py Normal file
View file

@ -0,0 +1,48 @@
import math
from dataclasses import fields
from app.sample import Sample
RowAgg = dict[str, float | int | None]
def window(snap: list[Sample], max_points: int) -> list[tuple[float, dict[str, RowAgg]]]:
"""Window-average a sample list down to at most `max_points` points.
The samples are split into consecutive chunks of ceil(n / max_points)
and each numeric Sample field is reduced to {avg, min, max} per chunk;
whole-number fields (byte counts) stay ints, fractional fields are
rounded to 0.1. Each point is stamped with the timestamp of the last
sample in its chunk.
Args:
snap: samples oldest first (HistoryStore.snapshot).
max_points: maximum number of points to emit.
Returns:
(timestamp, field aggregations) pairs, oldest first.
"""
n = len(snap)
w = max(1, math.ceil(n / max_points))
out: list[tuple[float, dict[str, RowAgg]]] = []
for start in range(0, n, w):
chunk = snap[start : start + w]
vals: dict[str, list[int | float]] = {}
for sample in chunk:
for f in fields(sample):
if f.name == "ts":
continue
v = getattr(sample, f.name)
if isinstance(v, (int, float)) and not isinstance(v, bool):
vals.setdefault(f.name, []).append(v)
row: dict[str, RowAgg] = {}
for k, lst in vals.items():
ints = all(isinstance(v, int) for v in lst)
avg = sum(lst) / len(lst)
row[k] = {
"avg": round(avg) if ints else round(avg, 1),
"min": min(lst) if ints else round(min(lst), 1),
"max": max(lst) if ints else round(max(lst), 1),
}
out.append((chunk[-1].ts, row))
return out

File diff suppressed because it is too large Load diff