Update inline documentation
This commit is contained in:
parent
d4a2ace913
commit
719690203f
36 changed files with 6429 additions and 27 deletions
|
|
@ -64,7 +64,12 @@ agent's own shell command line and kills the session.
|
||||||
|
|
||||||
## Conventions
|
## 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`.
|
- basedpyright is configured as linter, use with `uvx`.
|
||||||
- Match surrounding style; keep functions small and typed where the codebase already is.
|
- Match surrounding style; keep functions small and typed where the codebase already is.
|
||||||
- Keep polling endpoints cheap: collectors may cache lookups (unit names,
|
- Keep polling endpoints cheap: collectors may cache lookups (unit names,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ _temp_checked = False
|
||||||
|
|
||||||
|
|
||||||
def _read(path: str) -> str | 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:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
|
|
@ -17,6 +25,16 @@ def _read(path: str) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def _find_temp_path() -> 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*")):
|
for hwmon in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
|
||||||
name = (_read(f"{hwmon}/name") or "").lower()
|
name = (_read(f"{hwmon}/name") or "").lower()
|
||||||
if name in ("k10temp", "coretemp", "cpu_thermal"):
|
if name in ("k10temp", "coretemp", "cpu_thermal"):
|
||||||
|
|
@ -30,6 +48,14 @@ def _find_temp_path() -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def temp() -> float | 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
|
global _temp_path, _temp_checked
|
||||||
if not _temp_checked:
|
if not _temp_checked:
|
||||||
_temp_checked = True
|
_temp_checked = True
|
||||||
|
|
@ -47,14 +73,25 @@ def temp() -> float | None:
|
||||||
|
|
||||||
|
|
||||||
def prime() -> 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)
|
_ = psutil.cpu_percent(None)
|
||||||
|
|
||||||
|
|
||||||
def core_count() -> int:
|
def core_count() -> int:
|
||||||
|
"""Number of logical CPU cores (at least 1)."""
|
||||||
return psutil.cpu_count(logical=True) or 1
|
return psutil.cpu_count(logical=True) or 1
|
||||||
|
|
||||||
|
|
||||||
def fill(s: Sample) -> None:
|
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 = psutil.cpu_percent(None)
|
||||||
s.cpu_temp = temp()
|
s.cpu_temp = temp()
|
||||||
l1, l5, l15 = psutil.getloadavg()
|
l1, l5, l15 = psutil.getloadavg()
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,29 @@ from psutil._ntuples import sdiskio
|
||||||
|
|
||||||
|
|
||||||
def counters() -> dict[str, 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 {}
|
return psutil.disk_io_counters(perdisk=True) or {}
|
||||||
|
|
||||||
|
|
||||||
def rates(prev: dict[str, sdiskio], dt: float) -> tuple[float, float]:
|
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()
|
cur = counters()
|
||||||
r = 0
|
r = 0
|
||||||
w = 0
|
w = 0
|
||||||
|
|
@ -21,6 +40,17 @@ def rates(prev: dict[str, sdiskio], dt: float) -> tuple[float, float]:
|
||||||
|
|
||||||
|
|
||||||
def partitions() -> list[dict[str, Any]]:
|
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]] = {}
|
groups: dict[str, dict[str, Any]] = {}
|
||||||
order: list[str] = []
|
order: list[str] = []
|
||||||
for p in psutil.disk_partitions(all=False):
|
for p in psutil.disk_partitions(all=False):
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ _name_cache: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _read(path: str) -> str | 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:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
|
|
@ -17,6 +25,21 @@ def _read(path: str) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def shorten(name: str) -> str:
|
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()
|
name = re.sub(r"\s*\(rev.*\)$", "", name).strip()
|
||||||
groups = re.findall(r"\[([^\]]+)\]", name)
|
groups = re.findall(r"\[([^\]]+)\]", name)
|
||||||
if len(groups) >= 2:
|
if len(groups) >= 2:
|
||||||
|
|
@ -32,6 +55,15 @@ def shorten(name: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _gpu_name() -> 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
|
global _name_cache
|
||||||
if _name_cache is None:
|
if _name_cache is None:
|
||||||
_name_cache = "GPU"
|
_name_cache = "GPU"
|
||||||
|
|
@ -50,6 +82,20 @@ def _gpu_name() -> str:
|
||||||
|
|
||||||
|
|
||||||
def _amd(s: Sample) -> bool:
|
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"))
|
devices = sorted(glob.glob("/sys/class/drm/card[0-9]*/device/gpu_busy_percent"))
|
||||||
if not devices:
|
if not devices:
|
||||||
return False
|
return False
|
||||||
|
|
@ -86,6 +132,18 @@ def _amd(s: Sample) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def _nvidia(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"):
|
if not shutil.which("nvidia-smi"):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
|
|
@ -129,4 +187,12 @@ def _nvidia(s: Sample) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def fill(s: Sample) -> None:
|
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)
|
_ = _amd(s) or _nvidia(s)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ from app.sample import Sample
|
||||||
|
|
||||||
|
|
||||||
def fill(s: Sample) -> None:
|
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()
|
v = psutil.virtual_memory()
|
||||||
s.mem_used = v.used
|
s.mem_used = v.used
|
||||||
s.mem_total = v.total
|
s.mem_total = v.total
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,30 @@ _SSID_RE = re.compile(r"SSID:\s+(\S.*)")
|
||||||
|
|
||||||
|
|
||||||
def _wifi_ifaces() -> set[str]:
|
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")}
|
return {p.split("/")[-2] for p in glob.glob("/sys/class/net/*/wireless")}
|
||||||
|
|
||||||
|
|
||||||
def _ssid(iface: str) -> str | None:
|
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)
|
hit = _wifi_cache.get(iface)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if hit is not None and now - hit[0] < _WIFI_TTL:
|
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]:
|
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()
|
addrs = psutil.net_if_addrs()
|
||||||
stats = psutil.net_if_stats()
|
stats = psutil.net_if_stats()
|
||||||
wifi_set = _wifi_ifaces()
|
wifi_set = _wifi_ifaces()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,14 @@ _PS = "/sys/class/power_supply"
|
||||||
|
|
||||||
|
|
||||||
def _read(path: str) -> str | 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:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
|
|
@ -14,6 +22,12 @@ def _read(path: str) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def _supplies() -> list[tuple[str, str]]:
|
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]] = []
|
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 = _read(f"{p}/type")
|
||||||
|
|
@ -23,6 +37,17 @@ def _supplies() -> list[tuple[str, str]]:
|
||||||
|
|
||||||
|
|
||||||
def fill(s: Sample) -> None:
|
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:
|
try:
|
||||||
supplies = _supplies()
|
supplies = _supplies()
|
||||||
for t, p in supplies:
|
for t, p in supplies:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,15 @@ _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.
|
||||||
|
|
||||||
|
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
|
global _gpu_procs, _gpu_probe_t
|
||||||
if not shutil.which("nvidia-smi"):
|
if not shutil.which("nvidia-smi"):
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -43,6 +52,18 @@ def _gpu_per_proc() -> dict[int, int]:
|
||||||
|
|
||||||
|
|
||||||
def sample() -> list[dict[str, Any]]:
|
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()
|
now = time.monotonic()
|
||||||
mem_total = psutil.virtual_memory().total
|
mem_total = psutil.virtual_memory().total
|
||||||
gpu = _gpu_per_proc()
|
gpu = _gpu_per_proc()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
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")
|
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(env_prefix="DASH_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
|
|
@ -19,9 +26,19 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def history_maxlen(self) -> int:
|
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))
|
return max(10, int(self.retention_minutes * 60 / self.sample_interval))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
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()
|
return Settings()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,19 @@ FIELD_RE = re.compile(r"^([A-Z_][A-Z0-9_]*)=")
|
||||||
|
|
||||||
|
|
||||||
def parse_export(text: str) -> list[dict[str, Any]]:
|
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]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
cur: dict[str, Any] | None = None
|
cur: dict[str, Any] | None = None
|
||||||
last_key: str | 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]]:
|
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]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
ts = e.get("__REALTIME_TIMESTAMP")
|
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:
|
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(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*argv,
|
*argv,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
|
@ -81,6 +119,31 @@ async def tail(
|
||||||
lines: int,
|
lines: int,
|
||||||
hide_sudo: bool = False,
|
hide_sudo: bool = False,
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
) -> 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
|
fetch = lines * 2 if hide_sudo else lines
|
||||||
args = ["--no-pager", "-o", "export", "-n", str(min(max(fetch, 1), 500))]
|
args = ["--no-pager", "-o", "export", "-n", str(min(max(fetch, 1), 500))]
|
||||||
lvl = LEVELS.get(level)
|
lvl = LEVELS.get(level)
|
||||||
|
|
|
||||||
30
app/main.py
30
app/main.py
|
|
@ -17,6 +17,19 @@ from app.state import HistoryStore
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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()
|
settings = get_settings()
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.store = HistoryStore(maxlen=settings.history_maxlen)
|
app.state.store = HistoryStore(maxlen=settings.history_maxlen)
|
||||||
|
|
@ -40,10 +53,27 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
|
|
||||||
async def index():
|
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()))
|
return HTMLResponse(render("index.html", hostname=socket.gethostname()))
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
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 = FastAPI(title="Dashboard", lifespan=lifespan)
|
||||||
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
|
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):
|
for r in (overview.router, disks.router, processes.router, journal_router.router, services.router, plugins.router):
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,14 @@ from dataclasses import dataclass, field
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Plugin:
|
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
|
id: str
|
||||||
title: str
|
title: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
@ -12,14 +20,24 @@ class Plugin:
|
||||||
close_fn: Callable[[], Awaitable[None]] | None = field(default=None)
|
close_fn: Callable[[], Awaitable[None]] | None = field(default=None)
|
||||||
|
|
||||||
async def skeleton(self) -> str:
|
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:
|
if self.skeleton_fn is None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
return await self.skeleton_fn()
|
return await self.skeleton_fn()
|
||||||
|
|
||||||
async def open(self) -> None:
|
async def open(self) -> None:
|
||||||
|
"""Run the plugin's startup hook (no-op when not provided)."""
|
||||||
if self.open_fn is not None:
|
if self.open_fn is not None:
|
||||||
await self.open_fn()
|
await self.open_fn()
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
"""Run the plugin's shutdown hook (no-op when not provided)."""
|
||||||
if self.close_fn is not None:
|
if self.close_fn is not None:
|
||||||
await self.close_fn()
|
await self.close_fn()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,19 @@ _set_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
async def _run(args: list[str], timeout: float) -> tuple[str, str]:
|
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:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"lact", "cli", *args,
|
"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]]:
|
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]] = []
|
gpus: list[dict[str, str]] = []
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
m = re.match(r"^\s*(\d+):\s+(.*)$", line)
|
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]:
|
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
|
global _gpu_cache
|
||||||
if not force and _gpu_cache is not None:
|
if not force and _gpu_cache is not None:
|
||||||
ts, cached = _gpu_cache
|
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]:
|
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": ""}
|
entry: dict[str, Any] = {**g, "profiles": [], "active": None, "error": ""}
|
||||||
base = ["--gpu-id", g["id"], "profile"]
|
base = ["--gpu-id", g["id"], "profile"]
|
||||||
if with_profiles:
|
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]:
|
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)
|
gpus, err = await _gpus(force=force_gpus)
|
||||||
if err:
|
if err:
|
||||||
return {"gpus": [], "error": 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:
|
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 = await _gather(with_profiles=False)
|
||||||
data["message"] = message
|
data["message"] = message
|
||||||
data["error"] = error or data["error"]
|
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:
|
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 = await _gather(with_profiles=True, force_gpus=True)
|
||||||
data["message"] = message
|
data["message"] = message
|
||||||
data["error"] = error or data["error"]
|
data["error"] = error or data["error"]
|
||||||
|
|
@ -122,11 +200,27 @@ async def _skeleton(message: str = "", error: str = "") -> str:
|
||||||
|
|
||||||
@router.get("/state")
|
@router.get("/state")
|
||||||
async def state():
|
async def state():
|
||||||
|
"""Poll endpoint: return the compact state fragment."""
|
||||||
return HTMLResponse(await _state())
|
return HTMLResponse(await _state())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/set")
|
@router.post("/set")
|
||||||
async def set_profile(gpu_id: Annotated[str, Form()], profile: Annotated[str, Form()]):
|
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:
|
async with _set_lock:
|
||||||
data = await _gather(with_profiles=True, force_gpus=True)
|
data = await _gather(with_profiles=True, force_gpus=True)
|
||||||
if data["error"]:
|
if data["error"]:
|
||||||
|
|
@ -146,6 +240,17 @@ async def set_profile(gpu_id: Annotated[str, Form()], profile: Annotated[str, Fo
|
||||||
|
|
||||||
@router.post("/reload")
|
@router.post("/reload")
|
||||||
async def reload(gpu_id: Annotated[str | None, Form()] = None):
|
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
|
_ = gpu_id
|
||||||
return HTMLResponse(await _skeleton(message="profiles refreshed"))
|
return HTMLResponse(await _skeleton(message="profiles refreshed"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ router = APIRouter(prefix="/api/plugins/llamacpp", tags=["plugins"])
|
||||||
|
|
||||||
|
|
||||||
def _headers(settings: Settings) -> dict[str, str]:
|
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] = {}
|
h: dict[str, str] = {}
|
||||||
if settings.llama_api_key:
|
if settings.llama_api_key:
|
||||||
h["Authorization"] = f"Bearer {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:
|
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()
|
settings = get_settings()
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
base_url=settings.llama_base_url.rstrip("/"),
|
base_url=settings.llama_base_url.rstrip("/"),
|
||||||
|
|
@ -28,7 +43,18 @@ def _client() -> httpx.AsyncClient:
|
||||||
|
|
||||||
|
|
||||||
async def gather_status() -> dict[str, Any]:
|
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()
|
settings = get_settings()
|
||||||
status: dict[str, Any] = {
|
status: dict[str, Any] = {
|
||||||
"base_url": settings.llama_base_url,
|
"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]:
|
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:
|
try:
|
||||||
async with _client() as client:
|
async with _client() as client:
|
||||||
r = await client.post(endpoint, json={"model": model})
|
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]:
|
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")}
|
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["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]
|
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]:
|
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 = _with_lists(await gather_status())
|
||||||
status["message"] = message
|
status["message"] = message
|
||||||
status["error_msg"] = error
|
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:
|
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))
|
return render("plugins/llamacpp_state.html", **await _status(message, error))
|
||||||
|
|
||||||
|
|
||||||
async def _skeleton(message: str = "", error: str = "") -> str:
|
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))
|
return render("plugins/llamacpp_skeleton.html", **await _status(message, error))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/state")
|
@router.get("/state")
|
||||||
async def state():
|
async def state():
|
||||||
|
"""Poll endpoint: return the compact state fragment."""
|
||||||
return HTMLResponse(await _state())
|
return HTMLResponse(await _state())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/load")
|
@router.post("/load")
|
||||||
async def load(model: Annotated[str, Form()]):
|
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)
|
ok, err = await _action("/models/load", model)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
await _skeleton(
|
await _skeleton(
|
||||||
|
|
@ -131,6 +216,14 @@ async def load(model: Annotated[str, Form()]):
|
||||||
|
|
||||||
@router.post("/unload")
|
@router.post("/unload")
|
||||||
async def unload(model: Annotated[str, Form()]):
|
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)
|
ok, err = await _action("/models/unload", model)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
await _skeleton(
|
await _skeleton(
|
||||||
|
|
@ -142,6 +235,11 @@ async def unload(model: Annotated[str, Form()]):
|
||||||
|
|
||||||
@router.post("/rescan")
|
@router.post("/rescan")
|
||||||
async def 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:
|
try:
|
||||||
async with _client() as client:
|
async with _client() as client:
|
||||||
r = await client.get("/models", params={"reload": "1"})
|
r = await client.get("/models", params={"reload": "1"})
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,16 @@ _toggle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
async def _list() -> tuple[list[dict[str, Any]], str]:
|
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:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"systemd-inhibit", "--json=short", "--list",
|
"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:
|
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:
|
for e in inhibitors:
|
||||||
whats = str(e.get("what", "")).split(":")
|
whats = str(e.get("what", "")).split(":")
|
||||||
if "sleep" in whats and e.get("mode") in BLOCK_MODES:
|
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]]:
|
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]] = []
|
rows: list[dict[str, str | bool]] = []
|
||||||
for e in inhibitors:
|
for e in inhibitors:
|
||||||
mode = str(e.get("mode", ""))
|
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:
|
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
|
global _holder
|
||||||
if _holder is not None and _holder.returncode is not None:
|
if _holder is not None and _holder.returncode is not None:
|
||||||
_holder = None
|
_holder = None
|
||||||
|
|
||||||
|
|
||||||
def _context(inhibitors: list[dict[str, Any]], error: str, message: str = "") -> dict[str, Any]:
|
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()
|
_reap_dead_holder()
|
||||||
return {
|
return {
|
||||||
"inhibitors": _rows(inhibitors),
|
"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:
|
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()
|
inhibitors, err = await _list()
|
||||||
if error:
|
if error:
|
||||||
err = error
|
err = error
|
||||||
|
|
@ -105,6 +164,15 @@ async def _state(message: str = "", error: str = "") -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _skeleton(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()
|
inhibitors, err = await _list()
|
||||||
if error:
|
if error:
|
||||||
err = error
|
err = error
|
||||||
|
|
@ -112,6 +180,17 @@ async def _skeleton(message: str = "", error: str = "") -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _acquire() -> 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
|
global _holder
|
||||||
try:
|
try:
|
||||||
_holder = await asyncio.create_subprocess_exec(
|
_holder = await asyncio.create_subprocess_exec(
|
||||||
|
|
@ -131,6 +210,12 @@ async def _acquire() -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _release() -> None:
|
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
|
global _holder
|
||||||
p, _holder = _holder, None
|
p, _holder = _holder, None
|
||||||
if p is None:
|
if p is None:
|
||||||
|
|
@ -151,11 +236,25 @@ async def _release() -> None:
|
||||||
|
|
||||||
@router.get("/state")
|
@router.get("/state")
|
||||||
async def state():
|
async def state():
|
||||||
|
"""Poll endpoint: return the compact state fragment."""
|
||||||
return HTMLResponse(await _state())
|
return HTMLResponse(await _state())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/toggle")
|
@router.post("/toggle")
|
||||||
async def toggle(on: Annotated[str | None, Form()] = None):
|
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:
|
async with _toggle_lock:
|
||||||
if on and _holder is None:
|
if on and _holder is None:
|
||||||
err = await _acquire()
|
err = await _acquire()
|
||||||
|
|
@ -169,6 +268,13 @@ async def toggle(on: Annotated[str | None, Form()] = None):
|
||||||
|
|
||||||
|
|
||||||
async def _open() -> 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()
|
inhibitors, _err = await _list()
|
||||||
for e in inhibitors:
|
for e in inhibitors:
|
||||||
if e.get("who") != WHO:
|
if e.get("who") != WHO:
|
||||||
|
|
@ -183,6 +289,7 @@ async def _open() -> None:
|
||||||
|
|
||||||
|
|
||||||
async def _close() -> None:
|
async def _close() -> None:
|
||||||
|
"""Shutdown hook: release the lock if the UI left it on."""
|
||||||
await _release()
|
await _release()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ BASE = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
def humanize(value: float | str | None) -> str:
|
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:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
n = float(value)
|
n = float(value)
|
||||||
|
|
@ -21,6 +29,14 @@ def humanize(value: float | str | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
def rate(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:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
n = float(value)
|
n = float(value)
|
||||||
|
|
@ -34,6 +50,14 @@ def rate(value: float | str | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
def uptime_str(seconds: float | 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:
|
if seconds is None:
|
||||||
return "—"
|
return "—"
|
||||||
td = timedelta(seconds=int(seconds))
|
td = timedelta(seconds=int(seconds))
|
||||||
|
|
@ -50,6 +74,14 @@ def uptime_str(seconds: float | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
def pct(value: 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:
|
if value is None:
|
||||||
return "—"
|
return "—"
|
||||||
return f"{value:.0f}%"
|
return f"{value:.0f}%"
|
||||||
|
|
@ -66,4 +98,16 @@ env.filters["pct"] = pct
|
||||||
|
|
||||||
|
|
||||||
def render(name: str, **kwargs: Any) -> str:
|
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)
|
return env.get_template(name).render(**kwargs)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,18 @@ _prev_t: float = 0.0
|
||||||
|
|
||||||
@router.get("/disks")
|
@router.get("/disks")
|
||||||
async def disks(_request: Request):
|
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
|
global _prev, _prev_t
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
cur = disk_col.counters()
|
cur = disk_col.counters()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,24 @@ async def journal_view(
|
||||||
cursor: str = "",
|
cursor: str = "",
|
||||||
hide_sudo: 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:
|
if level not in journal.LEVELS:
|
||||||
level = "all"
|
level = "all"
|
||||||
lines = 200 if cursor else 100
|
lines = 200 if cursor else 100
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,21 @@ RowAgg = dict[str, float | int | None]
|
||||||
|
|
||||||
|
|
||||||
def _window(snap: list[Sample], max_points: int) -> list[tuple[float, dict[str, RowAgg]]]:
|
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)
|
n = len(snap)
|
||||||
w = max(1, math.ceil(n / max_points))
|
w = max(1, math.ceil(n / max_points))
|
||||||
out: list[tuple[float, dict[str, RowAgg]]] = []
|
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")
|
@router.get("/overview")
|
||||||
async def overview(request: Request):
|
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
|
store = request.app.state.store
|
||||||
s = store.latest() or Sample()
|
s = store.latest() or Sample()
|
||||||
mem_total = s.mem_total or 0
|
mem_total = s.mem_total or 0
|
||||||
|
|
@ -85,6 +112,20 @@ async def overview(request: Request):
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
async def history(request: Request):
|
async def history(request: Request):
|
||||||
|
"""Serve the ring buffer as chart data (JSON).
|
||||||
|
|
||||||
|
The buffer is window-averaged via _window() down to at most
|
||||||
|
`chart_max_points` points. Every key seen in any window gets avg/min/
|
||||||
|
max arrays, and each array is padded with None for windows that lack
|
||||||
|
the key (e.g. the GPU fields before a GPU is detected) so the arrays
|
||||||
|
stay aligned with the ts array — the charts rely on that.
|
||||||
|
|
||||||
|
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)
|
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()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def plugins_index():
|
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]] = []
|
items: list[dict[str, Plugin | str]] = []
|
||||||
for p in PLUGINS:
|
for p in PLUGINS:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,21 @@ SORT_KEYS = ("pid", "name", "cpu", "rss", "mem_pct", "io_read", "io_write", "gpu
|
||||||
|
|
||||||
@router.get("/processes")
|
@router.get("/processes")
|
||||||
async def processes(q: str = "", sort: str = "cpu", order: str = "desc"):
|
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:
|
if sort not in SORT_KEYS:
|
||||||
sort = "cpu"
|
sort = "cpu"
|
||||||
if order not in ("asc", "desc"):
|
if order not in ("asc", "desc"):
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ _ENABLED_RANK = {
|
||||||
|
|
||||||
|
|
||||||
def _rank(u: dict[str, Any], key: str) -> int:
|
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":
|
if key == "state":
|
||||||
return _STATE_RANK.get(u["active"], 9)
|
return _STATE_RANK.get(u["active"], 9)
|
||||||
if key == "enabled":
|
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:
|
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:
|
if sort not in SORT_KEYS:
|
||||||
sort = "name"
|
sort = "name"
|
||||||
if order not in ("asc", "desc"):
|
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("")
|
@router.get("")
|
||||||
async def services(q: str = "", sort: str = "name", order: str = "asc"):
|
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))
|
return HTMLResponse(await _list_fragment(q, sort, order))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{unit}/detail")
|
@router.get("/{unit}/detail")
|
||||||
async def service_detail(unit: str):
|
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
|
error = None
|
||||||
props: dict[str, str] = {}
|
props: dict[str, str] = {}
|
||||||
log: list[dict[str, str]] = []
|
log: list[dict[str, str]] = []
|
||||||
|
|
@ -100,6 +149,22 @@ async def service_action(
|
||||||
sort: Annotated[str, Form()] = "name",
|
sort: Annotated[str, Form()] = "name",
|
||||||
order: Annotated[str, Form()] = "asc",
|
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
|
error = None
|
||||||
try:
|
try:
|
||||||
_ = await units.unit_action(unit, action)
|
_ = await units.unit_action(unit, action)
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,33 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Sample:
|
class Sample:
|
||||||
ts: float = 0.0
|
"""One point of system state, sampled every `sample_interval` seconds.
|
||||||
cpu: float = 0.0
|
|
||||||
cpu_temp: float | None = None
|
Byte fields are in bytes, percentage fields are 0-100, temperatures
|
||||||
load1: float = 0.0
|
are degrees Celsius. A `None` value means the data is not available
|
||||||
load5: float = 0.0
|
on this machine (no GPU, no battery, no temperature sensor, ...).
|
||||||
load15: float = 0.0
|
"""
|
||||||
mem_used: int = 0
|
|
||||||
mem_total: int = 0
|
ts: float = 0.0 # unix time of the sample, set by HistoryStore.record
|
||||||
mem_pct: float = 0.0
|
cpu: float = 0.0 # aggregate CPU usage percent, 0-100
|
||||||
swap_used: int = 0
|
cpu_temp: float | None = None # CPU temperature °C, None = no sensor found
|
||||||
swap_total: int = 0
|
load1: float = 0.0 # 1-minute load average
|
||||||
swap_pct: float = 0.0
|
load5: float = 0.0 # 5-minute load average
|
||||||
gpu: float | None = None
|
load15: float = 0.0 # 15-minute load average
|
||||||
vram_used: int | None = None
|
mem_used: int = 0 # used RAM, bytes
|
||||||
vram_total: int | None = None
|
mem_total: int = 0 # total RAM, bytes
|
||||||
vram_pct: float | None = None
|
mem_pct: float = 0.0 # used RAM percent, 0-100
|
||||||
gpu_temp: float | None = None
|
swap_used: int = 0 # used swap, bytes
|
||||||
gpu_name: str = "no GPU detected"
|
swap_total: int = 0 # total swap, bytes
|
||||||
battery: int | None = None
|
swap_pct: float = 0.0 # used swap percent, 0-100
|
||||||
battery_status: str | None = None
|
gpu: float | None = None # GPU utilization percent, 0-100, None = no GPU
|
||||||
ac_online: bool | None = None
|
vram_used: int | None = None # used VRAM, bytes
|
||||||
io_read: float = 0.0
|
vram_total: int | None = None # total VRAM, bytes
|
||||||
io_write: float = 0.0
|
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
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,15 @@ from app.state import HistoryStore
|
||||||
|
|
||||||
|
|
||||||
def _collect() -> Sample:
|
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()
|
sample = Sample()
|
||||||
cpu.fill(sample)
|
cpu.fill(sample)
|
||||||
mem.fill(sample)
|
mem.fill(sample)
|
||||||
|
|
@ -16,6 +25,18 @@ def _collect() -> Sample:
|
||||||
|
|
||||||
|
|
||||||
async def sampler_loop(store: HistoryStore, sample_interval: float) -> None:
|
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()
|
cpu.prime()
|
||||||
prev_disk = disks.counters()
|
prev_disk = disks.counters()
|
||||||
prev_t = time.monotonic()
|
prev_t = time.monotonic()
|
||||||
|
|
|
||||||
25
app/state.py
25
app/state.py
|
|
@ -5,18 +5,43 @@ from app.sample import Sample
|
||||||
|
|
||||||
|
|
||||||
class HistoryStore:
|
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:
|
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)
|
self._buf: deque[Sample] = deque(maxlen=maxlen)
|
||||||
|
|
||||||
def record(self, sample: Sample) -> None:
|
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()
|
sample.ts = time.time()
|
||||||
self._buf.append(sample)
|
self._buf.append(sample)
|
||||||
|
|
||||||
def snapshot(self) -> list[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)
|
return list(self._buf)
|
||||||
|
|
||||||
def latest(self) -> Sample | None:
|
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
|
return self._buf[-1] if self._buf else None
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
|
"""Number of samples currently stored."""
|
||||||
return len(self._buf)
|
return len(self._buf)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ _DETAIL_PROPS = (
|
||||||
|
|
||||||
|
|
||||||
async def _run(cmd: list[str]) -> tuple[int, str, str]:
|
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(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
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:
|
async def _systemctl(*args: str, privileged: bool = False) -> str:
|
||||||
# Privileged verbs always go through sudo: plain attempts just get
|
"""Run a systemctl command and return its stdout.
|
||||||
# rejected by systemd and spam the journal with auth failures.
|
|
||||||
|
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])
|
cmd = (["sudo", "systemctl", *args] if privileged else ["systemctl", *args])
|
||||||
rc, out, err = await _run(cmd)
|
rc, out, err = await _run(cmd)
|
||||||
if rc == 0:
|
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]:
|
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
|
global _enabled_cache, _enabled_cache_at
|
||||||
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:
|
||||||
|
|
@ -53,6 +88,16 @@ 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.
|
||||||
|
|
||||||
|
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(
|
out = await _systemctl(
|
||||||
"list-units", "--type=service", "--all", "--no-legend", "--plain"
|
"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]:
|
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):
|
if not UNIT_RE.match(name):
|
||||||
raise ValueError("invalid unit name")
|
raise ValueError("invalid unit name")
|
||||||
out = await _systemctl("show", name, f"-p{_DETAIL_PROPS}")
|
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:
|
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):
|
if not UNIT_RE.match(name):
|
||||||
raise ValueError("invalid unit name")
|
raise ValueError("invalid unit name")
|
||||||
if action not in ACTIONS:
|
if action not in ACTIONS:
|
||||||
|
|
@ -111,6 +185,12 @@ async def unit_action(name: str, action: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def system_state() -> 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:
|
try:
|
||||||
return (await _systemctl("is-system-running")).strip() or "unknown"
|
return (await _systemctl("is-system-running")).strip() or "unknown"
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
|
|
||||||
5409
opencode/009_opencode_session_add_documentation_2026-08-30.txt
Normal file
5409
opencode/009_opencode_session_add_documentation_2026-08-30.txt
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue