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 - `app/collect/*` — collectors (cpu/mem/gpu/disks/procs/net/power) read
psutil + sysfs; `app/sampling.py` runs them every `DASH_SAMPLE_INTERVAL` psutil + sysfs; `app/sampling.py` runs them every `DASH_SAMPLE_INTERVAL`
(default 2 s) into an in-memory ring buffer (`app/state.py`). (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 - `app/routers/*` — each tab endpoint is an idempotent GET returning an htmx
HTML fragment; templates live in `templates/` and self-poll via HTML fragment; templates live in `templates/` and self-poll via
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. `hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`.
- `templates/*.html` auto-reload on file change — no restart needed for - `templates/*.html` auto-reload on file change — no restart needed for
template-only edits. Python changes require a restart. template-only edits. Python changes require a restart.
- `app/systemd/units.py` — systemd unit listing/detail/actions; - `app/journal.py` — `journalctl -o json` parser (one JSON object per line)
`app/journal.py``journalctl -o export` parser with cursors. with cursors.
- `app/plugins/``base.Plugin` (optional `open`/`close` lifecycle hooks run - `app/plugins/``base.Plugin` (optional `open`/`close` lifecycle hooks run
from app lifespan) + llamacpp plugin (talks to a router-mode `llama-server` from app lifespan) + llamacpp plugin (talks to a router-mode `llama-server`
on port 8080) + sleep plugin (lists block-mode `systemd-inhibit` locks; 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) 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 + lact plugin (shells out to `lact cli`: per-GPU profile dropdown with
set/reload, active profile polled every 5 s, GPU names shortened 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 ## Conventions
@ -79,9 +86,15 @@ agent's own shell command line and kills the session.
- Jinja autoescape renders `↓` as literal text — use literal unicode - Jinja autoescape renders `↓` as literal text — use literal unicode
(e.g. `↓`) in templates. (e.g. `↓`) in templates.
- `journalctl -o export` output contains NUL bytes (grep treats it as - `journalctl` is queried with `-o json` on purpose: the old `-o export`
binary); journalctl rejects negated matches (`!`/`!=`) — filter entries in 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. 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`); - psutil gotchas: there is no `psutil.AF_INET` (use `socket`);
`net_if_addrs()` / `net_if_stats()` take no arguments; `net_if_addrs()` / `net_if_stats()` take no arguments;
`sensors_battery().power_plugged` can be `None` — use `sensors_battery().power_plugged` can be `None` — use

View file

@ -3,27 +3,12 @@ import glob
import psutil import psutil
from app.sample import Sample from app.sample import Sample
from app.utils import sysfs
_temp_path: str | None = None _temp_path: str | None = None
_temp_checked = False _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: def _find_temp_path() -> str | None:
"""Find the sysfs file reporting CPU temperature, in millidegrees. """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. The sysfs file to read, or None if no suitable sensor exists.
""" """
for hwmon in sorted(glob.glob("/sys/class/hwmon/hwmon*")): 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"): if name in ("k10temp", "coretemp", "cpu_thermal"):
for t in sorted(glob.glob(f"{hwmon}/temp*_input")): for t in sorted(glob.glob(f"{hwmon}/temp*_input")):
return t return t
return None return None
for zone in sorted(glob.glob("/sys/class/thermal/thermal_zone*")): 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 f"{zone}/temp"
return None return None
@ -62,14 +47,10 @@ def temp() -> float | None:
_temp_path = _find_temp_path() _temp_path = _find_temp_path()
if _temp_path is None: if _temp_path is None:
return None return None
v = _read(_temp_path) v = sysfs.read_float(_temp_path)
if not v: if v is None:
return None return None
try: return round(v / 1000.0, 1)
n = float(v)
except ValueError:
return None
return round(n / 1000.0, 1)
def prime() -> None: def prime() -> None:

View file

@ -1,63 +1,24 @@
import glob import glob
import re import re
import shutil import shutil
import subprocess from typing import Any, cast
from app.sample import Sample 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 _name_cache: str | None = None
_LSPCI_QUOTED = re.compile(r'"([^"]*)"')
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]
def _gpu_name() -> str: def _gpu_name() -> str:
"""Resolve the display GPU name, cached for the process lifetime. """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 shortened with shorten(). Falls back to "GPU" if lspci is missing or
no matching device line is found. no matching device line is found.
@ -68,16 +29,13 @@ def _gpu_name() -> str:
if _name_cache is None: if _name_cache is None:
_name_cache = "GPU" _name_cache = "GPU"
if shutil.which("lspci"): if shutil.which("lspci"):
try: rc, out, _err = run(["lspci", "-mm"], timeout=5)
out = subprocess.run( if rc == 0:
["lspci"], capture_output=True, text=True, timeout=5, check=False
).stdout
for line in out.splitlines(): for line in out.splitlines():
if "VGA" in line or "3D controller" in line: f = _LSPCI_QUOTED.findall(line)
_name_cache = shorten(line.split(":", 2)[-1].strip()) if len(f) >= 3 and ("VGA" in f[0] or "3D controller" in f[0]):
_name_cache = shorten(f"{f[1]} {f[2]}")
break break
except (OSError, subprocess.SubprocessError):
pass
return _name_cache return _name_cache
@ -106,20 +64,14 @@ def _amd(s: Sample) -> bool:
temps: list[float] = [] temps: list[float] = []
for busy_path in devices: for busy_path in devices:
dev = busy_path.rsplit("/", 1)[0] dev = busy_path.rsplit("/", 1)[0]
try: busy_sum += sysfs.read_int(busy_path) or 0
busy_sum += int(_read(busy_path) or 0) count += 1
count += 1 vram_used += sysfs.read_int(f"{dev}/mem_info_vram_used") or 0
except ValueError: vram_total += sysfs.read_int(f"{dev}/mem_info_vram_total") or 0
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)
for hwmon in glob.glob(f"{dev}/hwmon/hwmon*"): for hwmon in glob.glob(f"{dev}/hwmon/hwmon*"):
t = _read(f"{hwmon}/temp1_input") t = sysfs.read_int(f"{hwmon}/temp1_input")
if t: if t is not None:
try: temps.append(t / 1000.0)
temps.append(int(t) / 1000.0)
except ValueError:
pass
if count == 0: if count == 0:
return False return False
s.gpu = round(busy_sum / count, 1) s.gpu = round(busy_sum / count, 1)
@ -134,9 +86,11 @@ def _amd(s: Sample) -> bool:
def _nvidia(s: Sample) -> bool: def _nvidia(s: Sample) -> bool:
"""Fill GPU fields by querying nvidia-smi. """Fill GPU fields by querying nvidia-smi.
Runs `nvidia-smi --query-gpu=...` (5 s timeout) and parses the Runs `nvidia-smi --query-gpu=... --format=json` (5 s timeout) and
CSV: busy percent averaged across GPUs, VRAM summed (MiB converted to parses the JSON array (keys are underscored, values strings): busy
bytes), temperature the hottest GPU, name from the first line. 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: Args:
s: sample to fill. s: sample to fill.
@ -146,38 +100,35 @@ def _nvidia(s: Sample) -> bool:
""" """
if not shutil.which("nvidia-smi"): if not shutil.which("nvidia-smi"):
return False return False
try: data, _err = run_json(
out = subprocess.run( [
[ "nvidia-smi",
"nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name",
"--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name", "--format=json",
"--format=csv,noheader,nounits", ],
], timeout=5,
capture_output=True, )
text=True, if not isinstance(data, list) or not data:
timeout=5,
check=True,
).stdout
except (OSError, subprocess.SubprocessError):
return False
lines = [l for l in out.splitlines() if l.strip()]
if not lines:
return False return False
rows = cast("list[dict[str, Any]]", data)
busy = used = total = 0 busy = used = total = 0
temp = 0 temp = 0
for line in lines: count = 0
parts = [p.strip() for p in line.split(",")] for e in rows:
try: try:
busy += int(parts[0]) busy += int(e["utilization_gpu"])
used += int(parts[1]) used += int(e["memory_used"])
total += int(parts[2]) total += int(e["memory_total"])
temp = max(temp, int(parts[3])) temp = max(temp, int(e["temperature_gpu"]))
except ValueError: count += 1
except (ValueError, TypeError, KeyError):
continue 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_used = used * 1024 * 1024
vram_total = total * 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_used = vram_used
s.vram_total = vram_total s.vram_total = vram_total
s.vram_pct = round(vram_used / vram_total * 100, 1) if vram_total else None 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 re
import shutil import shutil
import socket import socket
import subprocess
import time import time
from typing import Any from typing import Any
import psutil import psutil
from app.utils.subprocess import run
_wifi_cache: dict[str, tuple[float, str | None]] = {} _wifi_cache: dict[str, tuple[float, str | None]] = {}
_WIFI_TTL = 15.0 _WIFI_TTL = 15.0
_SSID_RE = re.compile(r"SSID:\s+(\S.*)") _SSID_RE = re.compile(r"SSID:\s+(\S.*)")
@ -44,15 +45,11 @@ def _ssid(iface: str) -> str | None:
return hit[1] return hit[1]
ssid: str | None = None ssid: str | None = None
if shutil.which("iw"): if shutil.which("iw"):
try: rc, out, _err = run(["iw", "dev", iface, "link"], timeout=3)
out = subprocess.run( if rc == 0:
["iw", "dev", iface, "link"], capture_output=True, text=True, timeout=3, check=False
).stdout
m = _SSID_RE.search(out) m = _SSID_RE.search(out)
if m: if m:
ssid = m.group(1).strip().strip('"') or None ssid = m.group(1).strip().strip('"') or None
except (OSError, subprocess.SubprocessError):
pass
_wifi_cache[iface] = (now, ssid) _wifi_cache[iface] = (now, ssid)
return ssid return ssid

View file

@ -1,26 +1,11 @@
import glob import glob
from app.sample import Sample from app.sample import Sample
from app.utils import sysfs
_PS = "/sys/class/power_supply" _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]]: def _supplies() -> list[tuple[str, str]]:
"""List power supplies found under /sys/class/power_supply. """List power supplies found under /sys/class/power_supply.
@ -30,7 +15,7 @@ def _supplies() -> list[tuple[str, str]]:
""" """
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
for p in sorted(glob.glob(f"{_PS}/*")): for p in sorted(glob.glob(f"{_PS}/*")):
t = _read(f"{p}/type") t = sysfs.read_str(f"{p}/type")
if t: if t:
out.append((t.lower(), p)) out.append((t.lower(), p))
return out return out
@ -51,22 +36,19 @@ def fill(s: Sample) -> None:
try: try:
supplies = _supplies() supplies = _supplies()
for t, p in supplies: for t, p in supplies:
if t == "battery" and _read(f"{p}/present") == "1": if t == "battery" and sysfs.read_str(f"{p}/present") == "1":
cap = _read(f"{p}/capacity") cap = sysfs.read_int(f"{p}/capacity")
if cap is not None: if cap is not None:
try: s.battery = cap
s.battery = int(cap) s.battery_status = sysfs.read_str(f"{p}/status")
except ValueError:
pass
s.battery_status = _read(f"{p}/status")
break break
for t, p in supplies: 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 s.ac_online = True
break break
if s.ac_online is None: if s.ac_online is None:
for t, p in supplies: 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 s.ac_online = True
break break
except OSError: except OSError:

View file

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

View file

@ -1,47 +1,40 @@
import asyncio import json
import re import re
from datetime import UTC, datetime 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;:=+./_-]+$") CURSOR_RE = re.compile(r"^[A-Za-z0-9;:=+./_-]+$")
LEVELS = {"all": None, "warn": "warning", "err": "err"} 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]]: def parse_lines(text: str) -> list[dict[str, Any]]:
"""Parse `journalctl -o export` output into entry dicts. """Parse `journalctl -o json` output into entry dicts.
The export format is `KEY=value` lines separated by blank lines; a Each non-empty line is one JSON object. Multi-line messages are
line that does not start with an uppercase key is a continuation of embedded as \\n escapes and control characters (e.g. NUL) are
the previous value (joined with newlines). Note the raw output can JSON-escaped, so no continuation-line handling is needed the
contain NUL bytes, which callers must tolerate. former -o export format required both.
Args: Args:
text: raw `journalctl -o export` output. text: raw `journalctl -o json` output.
Returns: 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]] = [] entries: list[dict[str, Any]] = []
cur: dict[str, Any] | None = None for line in text.splitlines():
last_key: str | None = None line = line.strip()
for raw in text.splitlines(): if not line:
if raw == "":
if cur is not None:
entries.append(cur)
cur, last_key = None, None
continue continue
m = FIELD_RE.match(raw) try:
if m: e = json.loads(line)
if cur is None: except ValueError:
cur = {} continue
last_key = m.group(1) if isinstance(e, dict):
if last_key is not None: entries.append(cast("dict[str, Any]", e))
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 return entries
@ -54,7 +47,7 @@ def format_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
SYSLOG_IDENTIFIER -> _COMM -> _PID. SYSLOG_IDENTIFIER -> _COMM -> _PID.
Args: Args:
entries: dicts from parse_export. entries: dicts from parse_lines.
Returns: Returns:
One row per kept entry with stamp, prio, ident, msg, cursor. 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. """Run a journalctl subprocess and return its stdout.
Args: Args:
argv: full command, e.g. ["sudo", "journalctl", "-n", "100"]. argv: full command, e.g. ["sudo", "journalctl", "-o", "json", "-n", "100"].
Returns: Returns:
The decoded stdout. The decoded stdout.
Raises: Raises:
RuntimeError: if journalctl exits non-zero; the message is its RuntimeError: if journalctl exits non-zero (or cannot be
stderr (or "journalctl failed" when stderr is empty). spawned); the message is its stderr (or "journalctl failed"
when stderr is empty).
""" """
proc = await asyncio.create_subprocess_exec( rc, out, err = await run_async(argv)
*argv, if rc != 0:
stdout=asyncio.subprocess.PIPE, raise RuntimeError(err.strip() or "journalctl failed")
stderr=asyncio.subprocess.PIPE, return out
)
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( async def tail(
@ -121,7 +110,7 @@ async def tail(
) -> tuple[list[dict[str, Any]], str | None]: ) -> tuple[list[dict[str, Any]], str | None]:
"""Fetch a recent journal page, newest entries last. """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 non-empty cursor is validated against CURSOR_RE before being passed
as --after-cursor (invalid cursors are silently ignored); level maps as --after-cursor (invalid cursors are silently ignored); level maps
through LEVELS, the unit name is regex-checked, and the free-text 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). RuntimeError: if journalctl fails (see _journalctl).
""" """
fetch = lines * 2 if hide_sudo else lines 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) lvl = LEVELS.get(level)
if lvl: if lvl:
args += ["-p", lvl] args += ["-p", lvl]
@ -157,7 +146,7 @@ async def tail(
args += ["--after-cursor", cursor] args += ["--after-cursor", cursor]
text = await _journalctl(["sudo", "journalctl"] + args) text = await _journalctl(["sudo", "journalctl"] + args)
entries = parse_export(text) entries = parse_lines(text)
if hide_sudo: if hide_sudo:
entries = [e for e in entries if e.get("SYSLOG_IDENTIFIER") != "sudo"] entries = [e for e in entries if e.get("SYSLOG_IDENTIFIER") != "sudo"]
entries = format_entries(entries) entries = format_entries(entries)

View file

@ -6,9 +6,10 @@ from typing import Annotated, Any
from fastapi import APIRouter, Form from fastapi import APIRouter, Form
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.collect.gpu import shorten
from app.plugins.base import Plugin from app.plugins.base import Plugin
from app.render import render 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"]) router = APIRouter(prefix="/api/plugins/lact", tags=["plugins"])
@ -34,27 +35,10 @@ async def _run(args: list[str], timeout: float) -> tuple[str, str]:
Returns: Returns:
(stdout, "") on success, else ("", error description). (stdout, "") on success, else ("", error description).
""" """
try: rc, out, err = await run_async(["lact", "cli", *args], timeout=timeout)
proc = await asyncio.create_subprocess_exec( if rc != 0:
"lact", "cli", *args, return "", (err.strip() or f"lact failed (rc={rc})")[:200]
stdout=asyncio.subprocess.PIPE, return out, ""
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"), ""
def _parse_gpus(out: str) -> list[dict[str, str]]: 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 / ...]) Each line looks like "0: <device> (Renoir [Radeon Vega Series / ...])
[Integrated]"; the parenthesised name is shortened with [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. Non-matching lines are skipped.
Args: Args:

View file

@ -1,5 +1,4 @@
import asyncio import asyncio
import json
import os import os
import signal import signal
from typing import Annotated, Any, cast from typing import Annotated, Any, cast
@ -9,6 +8,7 @@ from fastapi.responses import HTMLResponse
from app.plugins.base import Plugin from app.plugins.base import Plugin
from app.render import render from app.render import render
from app.utils.subprocess import run_json_async
router = APIRouter(prefix="/api/plugins/sleep", tags=["plugins"]) router = APIRouter(prefix="/api/plugins/sleep", tags=["plugins"])
@ -31,28 +31,9 @@ async def _list() -> tuple[list[dict[str, Any]], str]:
Returns: Returns:
(lock entries, "") on success, else ([], error description). (lock entries, "") on success, else ([], error description).
""" """
try: data, err = await run_json_async(["systemd-inhibit", "--json=short", "--list"], timeout=5)
proc = await asyncio.create_subprocess_exec( if err:
"systemd-inhibit", "--json=short", "--list", return [], err[:200]
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"
if not isinstance(data, list): if not isinstance(data, list):
return [], "unexpected systemd-inhibit output" return [], "unexpected systemd-inhibit output"
items: list[dict[str, Any]] = [e for e in cast("list[Any]", data) if isinstance(e, dict)] 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 asyncio
import math
import socket import socket
import time import time
from dataclasses import fields
from typing import Any from typing import Any
import psutil import psutil
@ -13,53 +11,10 @@ from app.collect import net as net_col
from app.config import get_settings from app.config import get_settings
from app.render import render, uptime_str from app.render import render, uptime_str
from app.sample import Sample from app.sample import Sample
from app.utils.window import window
router = APIRouter(prefix="/api", tags=["overview"]) 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") @router.get("/overview")
async def overview(request: Request): async def overview(request: Request):
@ -114,11 +69,11 @@ async def overview(request: Request):
async def history(request: Request): async def history(request: Request):
"""Serve the ring buffer as chart data (JSON). """Serve the ring buffer as chart data (JSON).
The buffer is window-averaged via _window() down to at most The buffer is window-averaged via app.utils.window.window() down to
`chart_max_points` points. Every key seen in any window gets avg/min/ at most `chart_max_points` points. Every key seen in any window gets
max arrays, and each array is padded with None for windows that lack avg/min/max arrays, and each array is padded with None for windows
the key (e.g. the GPU fields before a GPU is detected) so the arrays that lack the key (e.g. the GPU fields before a GPU is detected) so
stay aligned with the ts array the charts rely on that. the arrays stay aligned with the ts array the charts rely on that.
Args: Args:
request: FastAPI request (app.state.store). request: FastAPI request (app.state.store).
@ -126,7 +81,7 @@ async def history(request: Request):
Returns: Returns:
JSON with ts (unix seconds) and series: key to {avg, min, max}. 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] ts = [round(t, 1) for t, _ in snap]
keys: set[str] = set() keys: set[str] = set()
for _, row in snap: for _, row in snap:

View file

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from app import journal from app import journal
from app.render import render from app.render import render
from app.systemd import units from app.utils import systemd
router = APIRouter(prefix="/api/services", tags=["services"]) router = APIRouter(prefix="/api/services", tags=["services"])
@ -41,7 +41,7 @@ def _rank(u: dict[str, Any], key: str) -> int:
returns 0 here. returns 0 here.
Args: Args:
u: unit row from units.unit_list(). u: unit row from systemd.unit_list().
key: "state" or "enabled". key: "state" or "enabled".
Returns: Returns:
@ -74,7 +74,7 @@ async def _list_fragment(q: str, sort: str = "name", order: str = "asc", error:
sort = "name" sort = "name"
if order not in ("asc", "desc"): if order not in ("asc", "desc"):
order = "asc" order = "asc"
unit_list = await units.unit_list() unit_list = await systemd.unit_list()
if q: if q:
ql = q.lower() ql = q.lower()
unit_list = [ 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) unit_list.sort(key=lambda u: u["name"], reverse=reverse)
else: else:
unit_list.sort(key=lambda u: (_rank(u, sort), u["name"]), reverse=reverse) 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( return render(
"services.html", "services.html",
units=unit_list, units=unit_list,
@ -116,7 +116,7 @@ async def services(q: str = "", sort: str = "name", order: str = "asc"):
async def service_detail(unit: str): async def service_detail(unit: str):
"""Render the detail fragment for one service. """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 recent journal lines. A detail error suppresses the journal fetch and
is rendered as a banner. is rendered as a banner.
@ -130,7 +130,7 @@ async def service_detail(unit: str):
props: dict[str, str] = {} props: dict[str, str] = {}
log: list[dict[str, str]] = [] log: list[dict[str, str]] = []
try: try:
props = await units.unit_detail(unit) props = await systemd.unit_detail(unit)
except (ValueError, RuntimeError) as e: except (ValueError, RuntimeError) as e:
error = str(e)[:300] error = str(e)[:300]
if not error: if not error:
@ -157,7 +157,7 @@ async def service_action(
Args: Args:
unit: unit name. unit: unit name.
action: one of units.ACTIONS. action: one of systemd.ACTIONS.
q: search filter to keep. q: search filter to keep.
sort: column to sort by. sort: column to sort by.
order: "asc" or "desc". order: "asc" or "desc".
@ -167,7 +167,7 @@ async def service_action(
""" """
error = None error = None
try: try:
_ = await units.unit_action(unit, action) _ = await systemd.unit_action(unit, action)
except ValueError as e: except ValueError as e:
error = str(e) error = str(e)
except RuntimeError as 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 re
import time 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)$") UNIT_RE = re.compile(r"^[A-Za-z0-9@:_.\-+]+\.(service|socket|timer|target|path|slice)$")
ACTIONS = ("start", "stop", "restart", "enable", "disable") 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: async def _systemctl(*args: str, privileged: bool = False) -> str:
"""Run a systemctl command and return its stdout. """Run a systemctl command and return its stdout.
@ -47,11 +31,12 @@ async def _systemctl(*args: str, privileged: bool = False) -> str:
The decoded stdout. The decoded stdout.
Raises: Raises:
RuntimeError: if systemctl exits non-zero; the message is its RuntimeError: if systemctl exits non-zero (or cannot be spawned);
stderr (or "systemctl <args> failed" when stderr is empty). the message is its stderr (or "systemctl <args> failed" when
stderr is empty).
""" """
cmd = (["sudo", "systemctl", *args] if privileged else ["systemctl", *args]) 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: if rc == 0:
return out return out
raise RuntimeError(err.strip() or f"systemctl {' '.join(args)} failed") 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]: async def _enabled_map(force: bool = False) -> dict[str, str]:
"""Map unit name to enabled-state (enabled, disabled, static, ...). """Map unit name to enabled-state (enabled, disabled, static, ...).
The result of `systemctl list-unit-files --type=service` is cached The result of `systemctl list-unit-files --type=service --output=json`
module-wide for 30 s so fast polls don't re-run it; unit_action() is cached module-wide for 30 s so fast polls don't re-run it;
invalidates the cache after enable/disable. unit_action() invalidates the cache after enable/disable.
Args: Args:
force: bypass the cache and re-query. force: bypass the cache and re-query.
@ -75,13 +60,9 @@ async def _enabled_map(force: bool = False) -> dict[str, str]:
now = time.monotonic() now = time.monotonic()
if not force and _enabled_cache is not None and now - _enabled_cache_at < _ENABLED_TTL: if not force and _enabled_cache is not None and now - _enabled_cache_at < _ENABLED_TTL:
return _enabled_cache return _enabled_cache
files = await _systemctl("list-unit-files", "--type=service", "--no-legend", "--plain") out = await _systemctl("list-unit-files", "--type=service", "--output=json")
m: dict[str, str] = {} rows: list[dict[str, Any]] = json.loads(out)
for line in files.splitlines(): m: dict[str, str] = {e["unit_file"]: e["state"] for e in rows}
parts = line.split(None, 2)
if len(parts) < 2:
continue
m[parts[0]] = parts[1].strip()
_enabled_cache = m _enabled_cache = m
_enabled_cache_at = now _enabled_cache_at = now
return m return m
@ -90,32 +71,25 @@ async def _enabled_map(force: bool = False) -> dict[str, str]:
async def unit_list() -> list[dict[str, str]]: async def unit_list() -> list[dict[str, str]]:
"""List all service units with their runtime and enabled state. """List all service units with their runtime and enabled state.
Merges `systemctl list-units --all` (currently known units) with the Merges `systemctl list-units --all --output=json` (currently known
enabled-state map, so units that are configured but not active still units) with the enabled-state map, so units that are configured but
appear (with placeholder load/active/sub values). not active still appear (with placeholder load/active/sub values).
Returns: Returns:
One row per unit (name, load, active, sub, desc, enabled), One row per unit (name, load, active, sub, desc, enabled),
sorted by unit name. sorted by unit name.
""" """
out = await _systemctl( out = await _systemctl("list-units", "--type=service", "--all", "--output=json")
"list-units", "--type=service", "--all", "--no-legend", "--plain"
)
enabled = await _enabled_map() enabled = await _enabled_map()
units: dict[str, dict[str, str]] = {} units: dict[str, dict[str, str]] = {}
for line in out.splitlines(): for e in json.loads(out):
parts = line.split(None, 4) units[e["unit"]] = {
if len(parts) < 4: "name": e["unit"],
continue "load": e["load"],
name, load, active, sub = parts[0], parts[1], parts[2], parts[3] "active": e["active"],
desc = parts[4] if len(parts) > 4 else "" "sub": e["sub"],
units[name] = { "desc": e.get("description", ""),
"name": name, "enabled": enabled.get(e["unit"], ""),
"load": load,
"active": active,
"sub": sub,
"desc": desc,
"enabled": enabled.get(name, ""),
} }
for name, state in enabled.items(): for name, state in enabled.items():
if name not in units: 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