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
|
- `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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
except ValueError:
|
vram_used += sysfs.read_int(f"{dev}/mem_info_vram_used") or 0
|
||||||
continue
|
vram_total += sysfs.read_int(f"{dev}/mem_info_vram_total") or 0
|
||||||
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=csv,noheader,nounits",
|
"--format=json",
|
||||||
],
|
],
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
timeout=5,
|
||||||
check=True,
|
)
|
||||||
).stdout
|
if not isinstance(data, list) or not data:
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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=csv,noheader,nounits",
|
"--format=json",
|
||||||
],
|
],
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
timeout=5,
|
||||||
check=False
|
)
|
||||||
).stdout
|
if isinstance(data, list):
|
||||||
for line in out.splitlines():
|
for e in cast("list[dict[str, Any]]", data):
|
||||||
parts = [p.strip() for p in line.split(",")]
|
|
||||||
if len(parts) >= 2:
|
|
||||||
try:
|
try:
|
||||||
_gpu_procs[int(parts[0])] = int(parts[1])
|
_gpu_procs[int(e["pid"])] = int(e["used_memory"])
|
||||||
except ValueError:
|
except (ValueError, TypeError, KeyError):
|
||||||
continue
|
continue
|
||||||
except (OSError, subprocess.SubprocessError):
|
|
||||||
pass
|
|
||||||
return _gpu_procs
|
return _gpu_procs
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 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
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