Update inline documentation

This commit is contained in:
Johannes Schriewer 2026-08-30 23:29:32 +02:00
parent d4a2ace913
commit 719690203f
36 changed files with 6429 additions and 27 deletions

View file

@ -64,7 +64,12 @@ agent's own shell command line and kills the session.
## Conventions
- No code comments (the codebase has none).
- Google-style docstrings for every function and class: one-line imperative
summary, an `Args:` section for each parameter, and `Returns:`/`Raises:`
where non-obvious. Complex functions (parsers, subprocess wrappers,
anything touching the pitfalls below) get extra prose explaining the
behaviour, not just the signature.
- Inline comments are allowed only for `Sample` dataclass field docs.
- basedpyright is configured as linter, use with `uvx`.
- Match surrounding style; keep functions small and typed where the codebase already is.
- Keep polling endpoints cheap: collectors may cache lookups (unit names,

View file

@ -9,6 +9,14 @@ _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()
@ -17,6 +25,16 @@ def _read(path: str) -> str | None:
def _find_temp_path() -> str | None:
"""Find the sysfs file reporting CPU temperature, in millidegrees.
Prefers hwmon sensors named k10temp (AMD), coretemp (Intel), or
cpu_thermal (ARM), taking the first temp*_input of the first matching
hwmon; falls back to the acpitz thermal zone. The result is cached by
temp() for the process lifetime.
Returns:
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()
if name in ("k10temp", "coretemp", "cpu_thermal"):
@ -30,6 +48,14 @@ def _find_temp_path() -> str | None:
def temp() -> float | None:
"""Read the CPU temperature in degrees Celsius.
The sensor path is resolved once via _find_temp_path. Sysfs reports
millidegrees; the value is converted and rounded to 0.1 °C.
Returns:
Temperature in °C, or None if no sensor or unreadable value.
"""
global _temp_path, _temp_checked
if not _temp_checked:
_temp_checked = True
@ -47,14 +73,25 @@ def temp() -> float | None:
def prime() -> None:
"""Prime psutil's CPU percent counter so the next call has a real delta.
psutil.cpu_percent(None) returns 0.0 on its first call; sampler_loop
invokes this before the first sample for that reason.
"""
_ = psutil.cpu_percent(None)
def core_count() -> int:
"""Number of logical CPU cores (at least 1)."""
return psutil.cpu_count(logical=True) or 1
def fill(s: Sample) -> None:
"""Fill the cpu, cpu_temp, and load-average fields of a Sample.
Args:
s: sample to fill.
"""
s.cpu = psutil.cpu_percent(None)
s.cpu_temp = temp()
l1, l5, l15 = psutil.getloadavg()

View file

@ -5,10 +5,29 @@ from psutil._ntuples import sdiskio
def counters() -> dict[str, sdiskio]:
"""Current per-disk IO counters.
Returns:
A device-name to sdiskio mapping, or an empty dict on systems
without disk statistics.
"""
return psutil.disk_io_counters(perdisk=True) or {}
def rates(prev: dict[str, sdiskio], dt: float) -> tuple[float, float]:
"""Aggregate read/write rates in bytes/s since a previous counters call.
Disks that were not present in `prev` (hot-plugged) contribute
nothing, and negative byte deltas (counter wrap, reboots) are clamped
to zero.
Args:
prev: counters() result from the previous sample.
dt: seconds between the two snapshots.
Returns:
(read_bytes_per_s, write_bytes_per_s).
"""
cur = counters()
r = 0
w = 0
@ -21,6 +40,17 @@ def rates(prev: dict[str, sdiskio], dt: float) -> tuple[float, float]:
def partitions() -> list[dict[str, Any]]:
"""Mounted real filesystems, grouped by device.
All partitions on the same device are merged into one entry; usage
stats come from the first readable mountpoint, unreadable ones are
skipped. When a device has more than 3 mountpoints, mounts_disp shows
the first three plus "+N more".
Returns:
One entry per device (device, fstype, usage, mounts, mounts_disp),
sorted by device name.
"""
groups: dict[str, dict[str, Any]] = {}
order: list[str] = []
for p in psutil.disk_partitions(all=False):

View file

@ -9,6 +9,14 @@ _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()
@ -17,6 +25,21 @@ def _read(path: str) -> str | 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:
@ -32,6 +55,15 @@ def shorten(name: str) -> str:
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,
shortened with shorten(). Falls back to "GPU" if lspci is missing or
no matching device line is found.
Returns:
The display name to put on the overview card and Sample.
"""
global _name_cache
if _name_cache is None:
_name_cache = "GPU"
@ -50,6 +82,20 @@ def _gpu_name() -> str:
def _amd(s: Sample) -> bool:
"""Fill GPU fields from AMD sysfs (amdgpu driver).
Reads gpu_busy_percent, mem_info_vram_used/total, and hwmon
temp1_input (millidegrees) from each /sys/class/drm/card*/device.
Busy percent is averaged across cards, VRAM summed, temperature is the
hottest card. The display name comes from _gpu_name().
Args:
s: sample to fill.
Returns:
True if at least one card reported a busy percent, else False
(leaving s untouched).
"""
devices = sorted(glob.glob("/sys/class/drm/card[0-9]*/device/gpu_busy_percent"))
if not devices:
return False
@ -86,6 +132,18 @@ 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.
Args:
s: sample to fill.
Returns:
True if nvidia-smi exists and returned usable data, else False.
"""
if not shutil.which("nvidia-smi"):
return False
try:
@ -129,4 +187,12 @@ def _nvidia(s: Sample) -> bool:
def fill(s: Sample) -> None:
"""Fill the gpu / vram / gpu_temp / gpu_name fields of a Sample.
Tries the AMD sysfs path first (no subprocess), then nvidia-smi.
If neither applies, the fields keep their Sample defaults.
Args:
s: sample to fill.
"""
_ = _amd(s) or _nvidia(s)

View file

@ -4,6 +4,11 @@ from app.sample import Sample
def fill(s: Sample) -> None:
"""Fill the mem_* and swap_* fields of a Sample.
Args:
s: sample to fill (bytes and 0-100 percentages, via psutil).
"""
v = psutil.virtual_memory()
s.mem_used = v.used
s.mem_total = v.total

View file

@ -14,10 +14,30 @@ _SSID_RE = re.compile(r"SSID:\s+(\S.*)")
def _wifi_ifaces() -> set[str]:
"""Return the names of interfaces that are wireless.
Returns:
Interface names having a /sys/class/net/<name>/wireless entry.
"""
return {p.split("/")[-2] for p in glob.glob("/sys/class/net/*/wireless")}
def _ssid(iface: str) -> str | None:
r"""Get the SSID currently associated on a wifi interface.
Shells out to `iw dev <iface> link` and matches the unquoted
`SSID: name` line; the working regex is `SSID:\s+(\S.*)` (a `$` anchor
would only match the final line of the output without MULTILINE). The
result is
cached per interface for 15 s so the 2 s poll doesn't spawn a
subprocess every cycle.
Args:
iface: network interface name.
Returns:
The SSID, or None if not associated or `iw` is unavailable.
"""
hit = _wifi_cache.get(iface)
now = time.monotonic()
if hit is not None and now - hit[0] < _WIFI_TTL:
@ -38,6 +58,16 @@ def _ssid(iface: str) -> str | None:
def sample() -> dict[str, Any | None]:
"""Collect interface list and wifi association for the overview page.
Only interfaces that are up and are not the loopback are included;
each entry carries its IPv4 addresses. The first up wifi interface
(alphabetical order) provides the displayed SSID.
Returns:
A dict with "net_ifaces" (list of {name, ipv4}) and "net_wifi"
({iface, ssid} or None).
"""
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
wifi_set = _wifi_ifaces()

View file

@ -6,6 +6,14 @@ _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()
@ -14,6 +22,12 @@ def _read(path: str) -> str | None:
def _supplies() -> list[tuple[str, str]]:
"""List power supplies found under /sys/class/power_supply.
Returns:
(type, path) pairs sorted by path, where type is the sysfs type
("battery", "mains", "usb", ...) of each supply.
"""
out: list[tuple[str, str]] = []
for p in sorted(glob.glob(f"{_PS}/*")):
t = _read(f"{p}/type")
@ -23,6 +37,17 @@ def _supplies() -> list[tuple[str, str]]:
def fill(s: Sample) -> None:
"""Fill the battery / ac_online fields of a Sample from sysfs.
psutil's battery API is unreliable here (power_plugged can be None),
so /sys/class/power_supply/* is read directly: the first present
battery provides capacity and status, and ac_online becomes True when
any mains or, failing that, USB supply reports online. Fields stay
at their Sample defaults on a desktop without these nodes.
Args:
s: sample to fill.
"""
try:
supplies = _supplies()
for t, p in supplies:

View file

@ -11,6 +11,15 @@ _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.
Returns:
A pid to used-memory-in-MiB mapping.
"""
global _gpu_procs, _gpu_probe_t
if not shutil.which("nvidia-smi"):
return {}
@ -43,6 +52,18 @@ def _gpu_per_proc() -> dict[int, int]:
def sample() -> list[dict[str, Any]]:
"""One pass over all processes collecting cpu, memory, IO rate, GPU.
Processes whose parent is swapper/kthreadd (ppid 0/2) are skipped.
Per-process IO rates are byte deltas between successive calls divided
by elapsed time; previous readings are pruned when a process exits.
GPU memory comes from _gpu_per_proc(). Entries that die mid-iteration
are dropped, and per-process access errors are tolerated.
Returns:
A list of per-process dicts (pid, name, user, cpu, rss, mem_pct,
io_read, io_write, gpu), one per live process.
"""
now = time.monotonic()
mem_total = psutil.virtual_memory().total
gpu = _gpu_per_proc()

View file

@ -5,6 +5,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime configuration.
Values come from `DASH_`-prefixed environment variables or a local
`.env` file; unknown variables are ignored. See `.env.example` for the
full list of knobs.
"""
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(env_prefix="DASH_", env_file=".env", extra="ignore")
host: str = "127.0.0.1"
@ -19,9 +26,19 @@ class Settings(BaseSettings):
@property
def history_maxlen(self) -> int:
"""Ring buffer size for `retention_minutes` of samples (min 10).
Returns:
`retention_minutes * 60 / sample_interval`, at least 10.
"""
return max(10, int(self.retention_minutes * 60 / self.sample_interval))
@lru_cache
def get_settings() -> Settings:
"""Return the process-wide cached Settings instance.
Returns:
A Settings instance, parsed once and reused for the process lifetime.
"""
return Settings()

View file

@ -9,6 +9,19 @@ 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.
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.
Args:
text: raw `journalctl -o export` output.
Returns:
One dict per entry, key to value (multi-line values preserved).
"""
entries: list[dict[str, Any]] = []
cur: dict[str, Any] | None = None
last_key: str | None = None
@ -33,6 +46,19 @@ def parse_export(text: str) -> list[dict[str, Any]]:
def format_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Reduce raw export entries to the fields the journal tab renders.
Entries without a realtime timestamp are dropped. The local time is
formatted as HH:MM:SS (invalid timestamps render as an empty string),
PRIORITY defaults to 6 (info), and the identifier falls back
SYSLOG_IDENTIFIER -> _COMM -> _PID.
Args:
entries: dicts from parse_export.
Returns:
One row per kept entry with stamp, prio, ident, msg, cursor.
"""
out: list[dict[str, Any]] = []
for e in entries:
ts = e.get("__REALTIME_TIMESTAMP")
@ -62,6 +88,18 @@ def format_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
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"].
Returns:
The decoded stdout.
Raises:
RuntimeError: if journalctl exits non-zero; the message is its
stderr (or "journalctl failed" when stderr is empty).
"""
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
@ -81,6 +119,31 @@ async def tail(
lines: int,
hide_sudo: bool = False,
) -> 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
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
search is truncated to 200 chars. When hide_sudo is set, twice as many
lines are fetched (journalctl cannot express negated matches) and
sudo's own entries are filtered out in Python afterwards.
Args:
cursor: opaque journal cursor to continue after, or None.
level: one of "all" / "warn" / "err".
unit: systemd unit to filter on, or None.
search: free-text match, or None.
lines: target number of entries.
hide_sudo: drop entries logged by sudo itself.
Returns:
(formatted rows from format_entries, cursor of the newest row or
None when nothing was returned).
Raises:
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))]
lvl = LEVELS.get(level)

View file

@ -17,6 +17,19 @@ from app.state import HistoryStore
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Start shared state and run plugin lifecycle hooks around the app.
Startup: stores settings and the history ring buffer on `app.state`,
opens every plugin (a plugin `open()` failure is ignored, not fatal),
and spawns the background sampler task. Shutdown: cancels the
sampler task and closes every plugin.
Args:
app: the FastAPI instance.
Yields:
Control to the ASGI app for the server's lifetime.
"""
settings = get_settings()
app.state.settings = settings
app.state.store = HistoryStore(maxlen=settings.history_maxlen)
@ -40,10 +53,27 @@ async def lifespan(app: FastAPI):
async def index():
"""Serve the single-page dashboard shell at "/".
The shell only holds the tab bar and containers; each tab polls its
own `/api/*` endpoint for content, so this renders once and never again.
Returns:
The rendered `index.html` as an HTMLResponse.
"""
return HTMLResponse(render("index.html", hostname=socket.gethostname()))
def create_app() -> FastAPI:
"""Build the FastAPI application.
Wires up the `/static` mount, the six core routers (overview, disks,
processes, journal, services, plugins), and the routers contributed by
each plugin (see `app/plugins/__init__.py`).
Returns:
The configured FastAPI instance.
"""
app = FastAPI(title="Dashboard", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
for r in (overview.router, disks.router, processes.router, journal_router.router, services.router, plugins.router):

View file

@ -4,6 +4,14 @@ from dataclasses import dataclass, field
@dataclass
class Plugin:
"""A self-contained dashboard plugin.
Each plugin registers a router (mounted in create_app) and reports
itself here with a display title/description. `open`/`close` are
optional lifecycle hooks run from the app lifespan; `skeleton_fn`
renders the plugin's initial fragment for the Plugins tab.
"""
id: str
title: str
description: str = ""
@ -12,14 +20,24 @@ class Plugin:
close_fn: Callable[[], Awaitable[None]] | None = field(default=None)
async def skeleton(self) -> str:
"""Render the plugin's initial fragment.
Returns:
The HTML fragment for the Plugins tab.
Raises:
NotImplementedError: if no skeleton_fn was provided.
"""
if self.skeleton_fn is None:
raise NotImplementedError
return await self.skeleton_fn()
async def open(self) -> None:
"""Run the plugin's startup hook (no-op when not provided)."""
if self.open_fn is not None:
await self.open_fn()
async def close(self) -> None:
"""Run the plugin's shutdown hook (no-op when not provided)."""
if self.close_fn is not None:
await self.close_fn()

View file

@ -21,6 +21,19 @@ _set_lock = asyncio.Lock()
async def _run(args: list[str], timeout: float) -> tuple[str, str]:
"""Run `lact cli` with the given arguments, with a timeout.
The child is killed on timeout. All failure modes (binary missing,
other OSError, timeout, non-zero exit) are returned as a short error
string rather than raised.
Args:
args: lact cli arguments, e.g. ["list"] or ["--gpu-id", "0", "profile", "set", "balanced"].
timeout: seconds before the child is killed.
Returns:
(stdout, "") on success, else ("", error description).
"""
try:
proc = await asyncio.create_subprocess_exec(
"lact", "cli", *args,
@ -45,6 +58,19 @@ async def _run(args: list[str], timeout: float) -> tuple[str, str]:
def _parse_gpus(out: str) -> list[dict[str, str]]:
"""Parse `lact cli list` output into per-GPU entries.
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.
Non-matching lines are skipped.
Args:
out: stdout of `lact cli list`.
Returns:
One {id, name, type} dict per GPU.
"""
gpus: list[dict[str, str]] = []
for line in out.splitlines():
m = re.match(r"^\s*(\d+):\s+(.*)$", line)
@ -62,6 +88,14 @@ def _parse_gpus(out: str) -> list[dict[str, str]]:
async def _gpus(force: bool = False) -> tuple[list[dict[str, str]], str]:
"""List the GPUs known to lact, cached for 60 s.
Args:
force: bypass the cache and re-run `lact cli list`.
Returns:
(copies of the GPU entries, "") on success, else ([], error).
"""
global _gpu_cache
if not force and _gpu_cache is not None:
ts, cached = _gpu_cache
@ -76,6 +110,20 @@ async def _gpus(force: bool = False) -> tuple[list[dict[str, str]], str]:
async def _gpu_entry(g: dict[str, str], with_profiles: bool) -> dict[str, Any]:
"""Fetch the active profile (and optionally all profiles) for one GPU.
The `profile get` and `profile list` calls run concurrently when
with_profiles is set, so a full skeleton render only costs one
round trip of lact calls per GPU. A `get` failure is reported in the
entry's error field and skips the profile list.
Args:
g: GPU entry from _gpus() ({id, name, type}).
with_profiles: also fetch the list of available profiles.
Returns:
The GPU entry extended with profiles, active, and error.
"""
entry: dict[str, Any] = {**g, "profiles": [], "active": None, "error": ""}
base = ["--gpu-id", g["id"], "profile"]
if with_profiles:
@ -99,6 +147,15 @@ async def _gpu_entry(g: dict[str, str], with_profiles: bool) -> dict[str, Any]:
async def _gather(with_profiles: bool, force_gpus: bool = False) -> dict[str, Any]:
"""Collect status for all GPUs in one go.
Args:
with_profiles: include the available-profile lists.
force_gpus: bypass the GPU list cache.
Returns:
{"gpus": [per-GPU entries], "error": "" or an error string}.
"""
gpus, err = await _gpus(force=force_gpus)
if err:
return {"gpus": [], "error": err}
@ -107,6 +164,15 @@ async def _gather(with_profiles: bool, force_gpus: bool = False) -> dict[str, An
async def _state(message: str = "", error: str = "") -> str:
"""Render the compact state fragment (polling view, active profiles only).
Args:
message: transient success message, or "".
error: error to display (overrides gather errors), or "".
Returns:
The rendered lact_state.html fragment.
"""
data = await _gather(with_profiles=False)
data["message"] = message
data["error"] = error or data["error"]
@ -114,6 +180,18 @@ async def _state(message: str = "", error: str = "") -> str:
async def _skeleton(message: str = "", error: str = "") -> str:
"""Render the full skeleton fragment (initial + post-action view).
Always refreshes the GPU list and fetches every profile list, since
this is what the dropdowns are built from.
Args:
message: transient success message, or "".
error: error to display (overrides gather errors), or "".
Returns:
The rendered lact_skeleton.html fragment.
"""
data = await _gather(with_profiles=True, force_gpus=True)
data["message"] = message
data["error"] = error or data["error"]
@ -122,11 +200,27 @@ async def _skeleton(message: str = "", error: str = "") -> str:
@router.get("/state")
async def state():
"""Poll endpoint: return the compact state fragment."""
return HTMLResponse(await _state())
@router.post("/set")
async def set_profile(gpu_id: Annotated[str, Form()], profile: Annotated[str, Form()]):
"""Set a power profile on a GPU, then re-render the skeleton.
Serialized by a module-level lock (lact does not tolerate concurrent
profile sets). The requested gpu_id and profile are validated against
a fresh, forced gather unknown values are reported in the fragment.
Setting the already-active profile is a no-op with an explanatory
message.
Args:
gpu_id: GPU id from the form.
profile: profile name from the dropdown.
Returns:
The skeleton fragment with a success message or error.
"""
async with _set_lock:
data = await _gather(with_profiles=True, force_gpus=True)
if data["error"]:
@ -146,6 +240,17 @@ async def set_profile(gpu_id: Annotated[str, Form()], profile: Annotated[str, Fo
@router.post("/reload")
async def reload(gpu_id: Annotated[str | None, Form()] = None):
"""Refresh the profile lists by re-rendering the skeleton.
The gpu_id form field is accepted but ignored: the skeleton gather
always forces a full re-fetch of all GPUs and profiles.
Args:
gpu_id: submitted GPU id (unused).
Returns:
The skeleton fragment with a refresh message.
"""
_ = gpu_id
return HTMLResponse(await _skeleton(message="profiles refreshed"))

View file

@ -12,6 +12,15 @@ router = APIRouter(prefix="/api/plugins/llamacpp", tags=["plugins"])
def _headers(settings: Settings) -> dict[str, str]:
"""Build the request headers for llama-server calls.
Args:
settings: app settings (provides the optional API key).
Returns:
Headers including a Bearer Authorization only when
`DASH_LLAMA_API_KEY` is set.
"""
h: dict[str, str] = {}
if settings.llama_api_key:
h["Authorization"] = f"Bearer {settings.llama_api_key}"
@ -19,6 +28,12 @@ def _headers(settings: Settings) -> dict[str, str]:
def _client() -> httpx.AsyncClient:
"""Create an httpx client pointed at the configured llama-server.
Returns:
An AsyncClient with base URL, timeout, and auth headers from
settings (callers must use it as an async context manager).
"""
settings = get_settings()
return httpx.AsyncClient(
base_url=settings.llama_base_url.rstrip("/"),
@ -28,7 +43,18 @@ def _client() -> httpx.AsyncClient:
async def gather_status() -> dict[str, Any]:
"""Query the llama-server router. Never raises; returns status dict."""
"""Query the llama-server router for health and loaded-model status.
Hits /health and /models on the router endpoint. Per model it records
the router state (loading/loaded/sleeping/...), failure info, path,
and when the router reports progress an aggregate load percentage
(done/total summed over the progress fields). Never raises: any
failure is folded into the "error" field so the UI can still render.
Returns:
A dict with base_url, reachable, health, error, and models
(sorted by model id).
"""
settings = get_settings()
status: dict[str, Any] = {
"base_url": settings.llama_base_url,
@ -74,6 +100,16 @@ async def gather_status() -> dict[str, Any]:
async def _action(endpoint: str, model: str) -> tuple[bool, str]:
"""POST a load/unload action to the llama-server router.
Args:
endpoint: router endpoint path, "/models/load" or "/models/unload".
model: model id to act on.
Returns:
(True, "") on success, else (False, error description) covering
HTTP errors and unreachable-server cases.
"""
try:
async with _client() as client:
r = await client.post(endpoint, json={"model": model})
@ -90,6 +126,18 @@ async def _action(endpoint: str, model: str) -> tuple[bool, str]:
def _with_lists(status: dict[str, Any]) -> dict[str, Any]:
"""Split the model list into "loaded" and "available" for the UI.
A model counts as active while its state is loaded, sleeping, or
loading. "loaded" is sorted loaded-first, then sleeping, then by id;
"available" is sorted by id.
Args:
status: dict from gather_status.
Returns:
The same dict, mutated to carry the two extra lists.
"""
active = {m["id"] for m in status["models"] if m["state"] in ("loaded", "sleeping", "loading")}
status["loaded"] = [m for m in status["models"] if m["id"] in active]
status["available"] = [m for m in status["models"] if m["id"] not in active]
@ -99,6 +147,16 @@ def _with_lists(status: dict[str, Any]) -> dict[str, Any]:
async def _status(message: str, error: str) -> dict[str, Any]:
"""Build the template context: live status plus flash message/error.
Args:
message: transient success message to display, or "".
error: transient error message to display, or "".
Returns:
gather_status() output with loaded/available lists, message, and
error_msg added.
"""
status = _with_lists(await gather_status())
status["message"] = message
status["error_msg"] = error
@ -106,20 +164,47 @@ async def _status(message: str, error: str) -> dict[str, Any]:
async def _state(message: str = "", error: str = "") -> str:
"""Render the compact state fragment (polling view).
Args:
message: transient success message, or "".
error: transient error message, or "".
Returns:
The rendered llamacpp_state.html fragment.
"""
return render("plugins/llamacpp_state.html", **await _status(message, error))
async def _skeleton(message: str = "", error: str = "") -> str:
"""Render the full skeleton fragment (initial + post-action view).
Args:
message: transient success message, or "".
error: transient error message, or "".
Returns:
The rendered llamacpp_skeleton.html fragment.
"""
return render("plugins/llamacpp_skeleton.html", **await _status(message, error))
@router.get("/state")
async def state():
"""Poll endpoint: return the compact state fragment."""
return HTMLResponse(await _state())
@router.post("/load")
async def load(model: Annotated[str, Form()]):
"""Ask the router to load a model, then re-render the skeleton.
Args:
model: model id from the form.
Returns:
The skeleton fragment with a success message or error.
"""
ok, err = await _action("/models/load", model)
return HTMLResponse(
await _skeleton(
@ -131,6 +216,14 @@ async def load(model: Annotated[str, Form()]):
@router.post("/unload")
async def unload(model: Annotated[str, Form()]):
"""Ask the router to unload a model, then re-render the skeleton.
Args:
model: model id from the form.
Returns:
The skeleton fragment with a success message or error.
"""
ok, err = await _action("/models/unload", model)
return HTMLResponse(
await _skeleton(
@ -142,6 +235,11 @@ async def unload(model: Annotated[str, Form()]):
@router.post("/rescan")
async def rescan():
"""Ask the router to rescan its model directory, then re-render.
Returns:
The skeleton fragment with a refresh message or error.
"""
try:
async with _client() as client:
r = await client.get("/models", params={"reload": "1"})

View file

@ -21,6 +21,16 @@ _toggle_lock = asyncio.Lock()
async def _list() -> tuple[list[dict[str, Any]], str]:
"""List the currently active systemd inhibitor locks.
Runs `systemd-inhibit --json=short --list` with a 5 s timeout (the
child is killed on timeout). Every failure mode missing binary,
timeout, non-zero exit, bad JSON is returned as a short error
string rather than raised, so the UI can show a degraded state.
Returns:
(lock entries, "") on success, else ([], error description).
"""
try:
proc = await asyncio.create_subprocess_exec(
"systemd-inhibit", "--json=short", "--list",
@ -50,6 +60,17 @@ async def _list() -> tuple[list[dict[str, Any]], str]:
def _verdict(inhibitors: list[dict[str, Any]]) -> str:
"""Whether sleep is currently inhibited by anything.
Only locks whose "what" includes "sleep" AND whose mode is block or
block-weak actually prevent sleep (delay mode does not).
Args:
inhibitors: entries from _list().
Returns:
"blocked" or "ok".
"""
for e in inhibitors:
whats = str(e.get("what", "")).split(":")
if "sleep" in whats and e.get("mode") in BLOCK_MODES:
@ -58,6 +79,18 @@ def _verdict(inhibitors: list[dict[str, Any]]) -> str:
def _rows(inhibitors: list[dict[str, Any]]) -> list[dict[str, str | bool]]:
"""Shape block-mode inhibitor entries into table rows for the UI.
Delay-mode locks are skipped (they don't block sleep). The proc cell
shows "user · pid" when the lock has a live pid. The own flag marks
the lock held by this dashboard itself.
Args:
inhibitors: entries from _list().
Returns:
One row per block-mode lock: who, proc, what, why, mode, own.
"""
rows: list[dict[str, str | bool]] = []
for e in inhibitors:
mode = str(e.get("mode", ""))
@ -81,12 +114,29 @@ def _rows(inhibitors: list[dict[str, Any]]) -> list[dict[str, str | bool]]:
def _reap_dead_holder() -> None:
"""Forget the holder child if it has already exited on its own.
The systemd-inhibit child can die (e.g. the user killed it) without
going through _release(); checking returncode here keeps "holding" in
sync with reality.
"""
global _holder
if _holder is not None and _holder.returncode is not None:
_holder = None
def _context(inhibitors: list[dict[str, Any]], error: str, message: str = "") -> dict[str, Any]:
"""Build the template context shared by the state and skeleton fragments.
Args:
inhibitors: entries from _list().
error: error string to display (from _list or a caller), "".
message: transient success message to display, "".
Returns:
Context with inhibitors rows, verdict, message, error, and
holding (whether this dashboard holds a lock).
"""
_reap_dead_holder()
return {
"inhibitors": _rows(inhibitors),
@ -98,6 +148,15 @@ def _context(inhibitors: list[dict[str, Any]], error: str, message: str = "") ->
async def _state(message: str = "", error: str = "") -> str:
"""Render the compact state fragment (polling view).
Args:
message: transient success message, or "".
error: error to display (overrides the _list error), or "".
Returns:
The rendered sleep_state.html fragment.
"""
inhibitors, err = await _list()
if error:
err = error
@ -105,6 +164,15 @@ async def _state(message: str = "", error: str = "") -> str:
async def _skeleton(message: str = "", error: str = "") -> str:
"""Render the full skeleton fragment (initial + post-toggle view).
Args:
message: transient success message, or "".
error: error to display (overrides the _list error), or "".
Returns:
The rendered sleep_skeleton.html fragment.
"""
inhibitors, err = await _list()
if error:
err = error
@ -112,6 +180,17 @@ async def _skeleton(message: str = "", error: str = "") -> str:
async def _acquire() -> str:
"""Start the systemd-inhibit child that holds the dashboard's sleep lock.
The child runs `systemd-inhibit --what=sleep --mode=block ... sleep
infinity` in its own session, so the lock (identified by the WHO
marker) survives independently of this coroutine and can be reaped
by _open() on a restart. The whole child group is what _release()
later kills via os.killpg.
Returns:
"" on success, or a short error string.
"""
global _holder
try:
_holder = await asyncio.create_subprocess_exec(
@ -131,6 +210,12 @@ async def _acquire() -> str:
async def _release() -> None:
"""Release the dashboard's sleep lock by killing the holder child.
Clears the holder reference first (so re-entrant calls are safe),
sends SIGTERM to the child's whole process group, waits up to 3 s,
and escalates to SIGKILL if it is still alive.
"""
global _holder
p, _holder = _holder, None
if p is None:
@ -151,11 +236,25 @@ async def _release() -> None:
@router.get("/state")
async def state():
"""Poll endpoint: return the compact state fragment."""
return HTMLResponse(await _state())
@router.post("/toggle")
async def toggle(on: Annotated[str | None, Form()] = None):
"""Turn the dashboard's sleep lock on or off.
Guarded by a module-level lock so rapid double-clicks cannot start
two holders or race release against acquire. Toggling on acquires
the lock (errors are shown in the fragment, not raised); toggling
off releases it.
Args:
on: "on" to acquire, anything else to release.
Returns:
The skeleton fragment with a result message or error.
"""
async with _toggle_lock:
if on and _holder is None:
err = await _acquire()
@ -169,6 +268,13 @@ async def toggle(on: Annotated[str | None, Form()] = None):
async def _open() -> None:
"""Reap stale sleep locks left by a previous dashboard instance.
On startup, any block lock whose who marker is this dashboard's WHO
string belongs to a dead instance (the holder child does not survive
a restart), so it is SIGTERMed by pid. Locks held by other who
markers are never touched.
"""
inhibitors, _err = await _list()
for e in inhibitors:
if e.get("who") != WHO:
@ -183,6 +289,7 @@ async def _open() -> None:
async def _close() -> None:
"""Shutdown hook: release the lock if the UI left it on."""
await _release()

View file

@ -8,6 +8,14 @@ BASE = Path(__file__).resolve().parent.parent
def humanize(value: float | str | None) -> str:
"""Format a byte count as a human-readable string (e.g. "1.2 GiB").
Args:
value: number of bytes (a numeric string is accepted too).
Returns:
e.g. "512 B", "1.2 GiB", or "" when value is None.
"""
if value is None:
return ""
n = float(value)
@ -21,6 +29,14 @@ def humanize(value: float | str | None) -> str:
def rate(value: float | str | None) -> str:
"""Format a bytes-per-second rate as a human-readable string (e.g. "3.4 MiB/s").
Args:
value: transfer rate in bytes/s (a numeric string is accepted too).
Returns:
e.g. "128 B/s", "3.4 MiB/s", or "" when value is None.
"""
if value is None:
return ""
n = float(value)
@ -34,6 +50,14 @@ def rate(value: float | str | None) -> str:
def uptime_str(seconds: float | None) -> str:
"""Format a duration in seconds as a compact string (e.g. "3d 4h 12m").
Args:
seconds: duration in seconds.
Returns:
Compact duration, or "" when seconds is None.
"""
if seconds is None:
return ""
td = timedelta(seconds=int(seconds))
@ -50,6 +74,14 @@ def uptime_str(seconds: float | None) -> str:
def pct(value: float | None) -> str:
"""Format a 0-100 percentage rounded to a whole number (e.g. "42%").
Args:
value: percentage value.
Returns:
Rounded percentage string, or "" when value is None.
"""
if value is None:
return ""
return f"{value:.0f}%"
@ -66,4 +98,16 @@ env.filters["pct"] = pct
def render(name: str, **kwargs: Any) -> str:
"""Render a Jinja template from `templates/` with the shared environment.
The environment has HTML autoescape on and the `humanize`, `rate`,
`uptime`, and `pct` filters registered.
Args:
name: template path relative to `templates/`, e.g. "overview.html".
**kwargs: template context variables.
Returns:
The rendered HTML as a string.
"""
return env.get_template(name).render(**kwargs)

View file

@ -15,6 +15,18 @@ _prev_t: float = 0.0
@router.get("/disks")
async def disks(_request: Request):
"""Render the Disks tab fragment: partition usage + per-disk rates.
Per-disk read/write rates are computed from the delta between this
request's counters and the previous request's (module-level state,
so rates depend on poll frequency and are 0 on the first hit).
Args:
_request: FastAPI request (unused beyond app state access).
Returns:
The rendered disks.html as an HTMLResponse.
"""
global _prev, _prev_t
now = time.monotonic()
cur = disk_col.counters()

View file

@ -18,6 +18,24 @@ async def journal_view(
cursor: str = "",
hide_sudo: str = "",
):
"""Render the Journal tab fragment: a page of journal entries.
Without a cursor it fetches 100 lines; with one (continuing a scroll)
200, then keeps the newest 400 for the template. level is validated
against journal.LEVELS, and failures (RuntimeError/OSError from
journalctl) are rendered as an error banner instead of a 500.
Args:
_request: FastAPI request (unused).
level: "all" / "warn" / "err".
unit: unit name filter, empty for none.
search: free-text filter, empty for none.
cursor: journal cursor to continue after, empty for none.
hide_sudo: "on" to hide sudo's own log entries.
Returns:
The rendered journal.html as an HTMLResponse.
"""
if level not in journal.LEVELS:
level = "all"
lines = 200 if cursor else 100

View file

@ -20,6 +20,21 @@ 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]]] = []
@ -48,6 +63,18 @@ def _window(snap: list[Sample], max_points: int) -> list[tuple[float, dict[str,
@router.get("/overview")
async def overview(request: Request):
"""Render the Overview tab fragment: current system state card.
Takes the latest sample from the history store (an empty Sample when
none exists yet), derives vram_pct when the collector left it unset,
and adds interface / wifi data and uptime.
Args:
request: FastAPI request (app.state.store).
Returns:
The rendered overview.html as an HTMLResponse.
"""
store = request.app.state.store
s = store.latest() or Sample()
mem_total = s.mem_total or 0
@ -85,6 +112,20 @@ async def overview(request: Request):
@router.get("/history")
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.
Args:
request: FastAPI request (app.state.store).
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)
ts = [round(t, 1) for t, _ in snap]
keys: set[str] = set()

View file

@ -9,6 +9,14 @@ router = APIRouter(prefix="/api/plugins", tags=["plugins"])
@router.get("")
async def plugins_index():
"""Render the Plugins tab: a skeleton fragment for every registered plugin.
A plugin whose skeleton() raises gets an inline error card instead of
taking down the whole page.
Returns:
The rendered plugins.html as an HTMLResponse.
"""
items: list[dict[str, Plugin | str]] = []
for p in PLUGINS:
try:

View file

@ -13,6 +13,21 @@ SORT_KEYS = ("pid", "name", "cpu", "rss", "mem_pct", "io_read", "io_write", "gpu
@router.get("/processes")
async def processes(q: str = "", sort: str = "cpu", order: str = "desc"):
"""Render the Processes tab fragment: filterable, sortable process table.
The full sample is taken in a worker thread, then optionally filtered
by substring match on name or exact match on pid. Sorting is done with
None values last (the tuple key trick); at most 300 rows are rendered.
Invalid sort/order values fall back to cpu/desc.
Args:
q: search filter, empty for all.
sort: column to sort by, one of SORT_KEYS.
order: "asc" or "desc".
Returns:
The rendered processes.html as an HTMLResponse.
"""
if sort not in SORT_KEYS:
sort = "cpu"
if order not in ("asc", "desc"):

View file

@ -35,6 +35,18 @@ _ENABLED_RANK = {
def _rank(u: dict[str, Any], key: str) -> int:
"""Sort rank of a unit row for the state/enabled columns.
Unknown states rank last (9); "name" sorting uses the raw string and
returns 0 here.
Args:
u: unit row from units.unit_list().
key: "state" or "enabled".
Returns:
An integer rank, lower first.
"""
if key == "state":
return _STATE_RANK.get(u["active"], 9)
if key == "enabled":
@ -43,6 +55,21 @@ def _rank(u: dict[str, Any], key: str) -> int:
async def _list_fragment(q: str, sort: str = "name", order: str = "asc", error: str | None = None) -> str:
"""Render the services list fragment (shared by GET and POST endpoints).
Filters by substring match on unit name or description, sorts by name
or by state/enabled rank (with the unit name as tiebreaker), and
renders services.html including the overall system state.
Args:
q: search filter, empty for all.
sort: one of SORT_KEYS.
order: "asc" or "desc".
error: error message to show in the fragment, if any.
Returns:
The rendered services.html fragment.
"""
if sort not in SORT_KEYS:
sort = "name"
if order not in ("asc", "desc"):
@ -72,11 +99,33 @@ async def _list_fragment(q: str, sort: str = "name", order: str = "asc", error:
@router.get("")
async def services(q: str = "", sort: str = "name", order: str = "asc"):
"""Render the Services tab fragment.
Args:
q: search filter, empty for all.
sort: one of SORT_KEYS.
order: "asc" or "desc".
Returns:
The services list as an HTMLResponse.
"""
return HTMLResponse(await _list_fragment(q, sort, order))
@router.get("/{unit}/detail")
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
recent journal lines. A detail error suppresses the journal fetch and
is rendered as a banner.
Args:
unit: unit name, e.g. "sshd.service".
Returns:
The rendered service_detail.html as an HTMLResponse.
"""
error = None
props: dict[str, str] = {}
log: list[dict[str, str]] = []
@ -100,6 +149,22 @@ async def service_action(
sort: Annotated[str, Form()] = "name",
order: Annotated[str, Form()] = "asc",
):
"""Perform a start/stop/restart/enable/disable on a unit and re-render the list.
The form carries the current q/sort/order so the htmx swap shows the
updated list with the same view. Errors from unit_action are rendered
in the fragment instead of raising.
Args:
unit: unit name.
action: one of units.ACTIONS.
q: search filter to keep.
sort: column to sort by.
order: "asc" or "desc".
Returns:
The re-rendered services list as an HTMLResponse.
"""
error = None
try:
_ = await units.unit_action(unit, action)

View file

@ -3,26 +3,33 @@ from dataclasses import dataclass
@dataclass
class Sample:
ts: float = 0.0
cpu: float = 0.0
cpu_temp: float | None = None
load1: float = 0.0
load5: float = 0.0
load15: float = 0.0
mem_used: int = 0
mem_total: int = 0
mem_pct: float = 0.0
swap_used: int = 0
swap_total: int = 0
swap_pct: float = 0.0
gpu: float | None = None
vram_used: int | None = None
vram_total: int | None = None
vram_pct: float | None = None
gpu_temp: float | None = None
gpu_name: str = "no GPU detected"
battery: int | None = None
battery_status: str | None = None
ac_online: bool | None = None
io_read: float = 0.0
io_write: float = 0.0
"""One point of system state, sampled every `sample_interval` seconds.
Byte fields are in bytes, percentage fields are 0-100, temperatures
are degrees Celsius. A `None` value means the data is not available
on this machine (no GPU, no battery, no temperature sensor, ...).
"""
ts: float = 0.0 # unix time of the sample, set by HistoryStore.record
cpu: float = 0.0 # aggregate CPU usage percent, 0-100
cpu_temp: float | None = None # CPU temperature °C, None = no sensor found
load1: float = 0.0 # 1-minute load average
load5: float = 0.0 # 5-minute load average
load15: float = 0.0 # 15-minute load average
mem_used: int = 0 # used RAM, bytes
mem_total: int = 0 # total RAM, bytes
mem_pct: float = 0.0 # used RAM percent, 0-100
swap_used: int = 0 # used swap, bytes
swap_total: int = 0 # total swap, bytes
swap_pct: float = 0.0 # used swap percent, 0-100
gpu: float | None = None # GPU utilization percent, 0-100, None = no GPU
vram_used: int | None = None # used VRAM, bytes
vram_total: int | None = None # total VRAM, bytes
vram_pct: float | None = None # used VRAM percent, 0-100
gpu_temp: float | None = None # GPU temperature °C
gpu_name: str = "no GPU detected" # display name (shortened lspci / nvidia-smi name)
battery: int | None = None # battery capacity percent, 0-100, None = no battery
battery_status: str | None = None # "Charging" / "Discharging" / "Full" / ...
ac_online: bool | None = None # True/False when a mains/USB supply exists, None otherwise
io_read: float = 0.0 # aggregate disk read rate, bytes/s
io_write: float = 0.0 # aggregate disk write rate, bytes/s

View file

@ -7,6 +7,15 @@ from app.state import HistoryStore
def _collect() -> Sample:
"""Fill a fresh Sample with one synchronous collector pass.
Runs in a worker thread (see sampler_loop) because the collectors hit
sysfs and psutil. Disk read/write rates are intentionally not set here:
they need the delta between two samples, which sampler_loop keeps.
Returns:
A Sample with cpu, load, memory, swap, GPU, and power fields filled.
"""
sample = Sample()
cpu.fill(sample)
mem.fill(sample)
@ -16,6 +25,18 @@ def _collect() -> Sample:
async def sampler_loop(store: HistoryStore, sample_interval: float) -> None:
"""Sample the system into the store every `sample_interval` seconds, forever.
Before the first sample it primes `psutil.cpu_percent` (its first call
always returns 0) and takes a baseline disk-counter reading, so the
first stored sample already carries valid CPU and disk rates. Each loop
collects in a worker thread, then computes per-disk byte deltas divided
by the elapsed time as the aggregate io_read / io_write rates.
Args:
store: ring buffer that receives each sample.
sample_interval: seconds between samples.
"""
cpu.prime()
prev_disk = disks.counters()
prev_t = time.monotonic()

View file

@ -5,18 +5,43 @@ from app.sample import Sample
class HistoryStore:
"""In-memory ring buffer of Sample points, oldest dropped first.
`maxlen` is derived from `DASH_RETENTION_MINUTES` / `DASH_SAMPLE_INTERVAL`
(see `Settings.history_maxlen`). All methods are called from the event
loop thread; the sampler's collection work happens in a worker thread
before `record` is called, so no locking is needed.
"""
def __init__(self, maxlen: int) -> None:
"""Create an empty store.
Args:
maxlen: maximum number of samples to keep.
"""
self._buf: deque[Sample] = deque(maxlen=maxlen)
def record(self, sample: Sample) -> None:
"""Stamp the sample with the current unix time and append it.
Args:
sample: sample to store; its `ts` field is overwritten.
"""
sample.ts = time.time()
self._buf.append(sample)
def snapshot(self) -> list[Sample]:
"""Return all stored samples, oldest first.
Returns:
A copy of the buffer contents as a list.
"""
return list(self._buf)
def latest(self) -> Sample | None:
"""Return the newest sample, or None if the store is empty."""
return self._buf[-1] if self._buf else None
def __len__(self) -> int:
"""Number of samples currently stored."""
return len(self._buf)

View file

@ -16,6 +16,15 @@ _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,
@ -26,8 +35,21 @@ async def _run(cmd: list[str]) -> tuple[int, str, str]:
async def _systemctl(*args: str, privileged: bool = False) -> str:
# Privileged verbs always go through sudo: plain attempts just get
# rejected by systemd and spam the journal with auth failures.
"""Run a systemctl command and return its stdout.
Args:
*args: systemctl subcommand and options, e.g. ("show", "foo.service").
privileged: run via sudo. Set for verbs that modify state (start,
stop, enable, ...); plain attempts just get rejected by
systemd and spam the journal with auth failures.
Returns:
The decoded stdout.
Raises:
RuntimeError: if systemctl exits non-zero; 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)
if rc == 0:
@ -36,6 +58,19 @@ 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.
Args:
force: bypass the cache and re-query.
Returns:
A unit-name to state-string mapping (may include units that are
not currently active).
"""
global _enabled_cache, _enabled_cache_at
now = time.monotonic()
if not force and _enabled_cache is not None and now - _enabled_cache_at < _ENABLED_TTL:
@ -53,6 +88,16 @@ 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).
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"
)
@ -86,6 +131,19 @@ async def unit_list() -> list[dict[str, str]]:
async def unit_detail(name: str) -> dict[str, str]:
"""Fetch the detail properties of one unit via `systemctl show`.
Args:
name: unit name, must match UNIT_RE.
Returns:
The requested properties (see _DETAIL_PROPS) as a key to value
mapping, empty values included.
Raises:
ValueError: if the unit name is not a valid systemd unit name.
RuntimeError: if systemctl fails.
"""
if not UNIT_RE.match(name):
raise ValueError("invalid unit name")
out = await _systemctl("show", name, f"-p{_DETAIL_PROPS}")
@ -98,6 +156,22 @@ async def unit_detail(name: str) -> dict[str, str]:
async def unit_action(name: str, action: str) -> str:
"""Perform a state-changing verb on a unit (via sudo).
enable/disable also invalidate the module-level enabled-state cache
so the next unit_list() reflects the change immediately.
Args:
name: unit name, must match UNIT_RE.
action: one of ACTIONS (start, stop, restart, enable, disable).
Returns:
The (usually empty) stdout of the systemctl call.
Raises:
ValueError: if the unit name or action is invalid.
RuntimeError: if systemctl fails (e.g. unit does not exist).
"""
if not UNIT_RE.match(name):
raise ValueError("invalid unit name")
if action not in ACTIONS:
@ -111,6 +185,12 @@ async def unit_action(name: str, action: str) -> str:
async def system_state() -> str:
"""Overall systemd state (running, degraded, ..., or "unknown").
Returns:
The `systemctl is-system-running` state, or "unknown" when the
call fails (e.g. inside a container).
"""
try:
return (await _systemctl("is-system-running")).strip() or "unknown"
except RuntimeError:

File diff suppressed because it is too large Load diff