Refacor: Use JSON outputs of command line utils as much as possible and
generalize sysfs and subprocess code
This commit is contained in:
parent
719690203f
commit
4f8fc0f361
19 changed files with 6486 additions and 391 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
from app.systemd import units
|
||||
|
||||
__all__ = ["units"]
|
||||
3
app/utils/__init__.py
Normal file
3
app/utils/__init__.py
Normal 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
31
app/utils/gpu.py
Normal 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
104
app/utils/subprocess.py
Normal 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
52
app/utils/sysfs.py
Normal 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
|
||||
|
|
@ -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
48
app/utils/window.py
Normal 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
|
||||
6058
opencode/010_opencode_session_refactor_json_dry_2026-08-31.txt
Normal file
6058
opencode/010_opencode_session_refactor_json_dry_2026-08-31.txt
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue