Implement GPU Profile switcher plugin

This commit is contained in:
Johannes Schriewer 2026-08-30 22:20:24 +02:00
parent 14f44f20fe
commit fbd4b6c5e8
9 changed files with 3961 additions and 9 deletions

View file

@ -8,7 +8,8 @@ A single-host computer dashboard and task manager: FastAPI (Python ≥ 3.12,
managed with `uv`) backend serving htmx + Chart.js HTML fragments to one managed with `uv`) backend serving htmx + Chart.js HTML fragments to one
browser page. Binds to `127.0.0.1:8501`, no auth by design (see the README browser page. Binds to `127.0.0.1:8501`, no auth by design (see the README
security section). Tabs: Overview, Disks, Processes, Journal, Services security section). Tabs: Overview, Disks, Processes, Journal, Services
(systemd), Plugins (llama.cpp). Licensed under the Unlicense (see `LICENSE`). (systemd), Plugins (llama.cpp, sleep, LACT). Licensed under the Unlicense
(see `LICENSE`).
## Commands ## Commands
@ -56,7 +57,10 @@ agent's own shell command line and kills the session.
from app lifespan) + llamacpp plugin (talks to a router-mode `llama-server` from app lifespan) + llamacpp plugin (talks to a router-mode `llama-server`
on port 8080) + sleep plugin (lists block-mode `systemd-inhibit` locks; on port 8080) + sleep plugin (lists block-mode `systemd-inhibit` locks;
holds its own sleep lock via a `systemd-inhibit ... sleep infinity` child holds its own sleep lock via a `systemd-inhibit ... sleep infinity` child
while the UI switch is on, reaps stale locks by `who` marker on startup). while the UI switch is on, reaps stale locks by `who` marker on startup)
+ lact plugin (shells out to `lact cli`: per-GPU profile dropdown with
set/reload, active profile polled every 5 s, GPU names shortened with
`app/collect/gpu.py:_shorten` like the overview card).
## Conventions ## Conventions

View file

@ -81,10 +81,12 @@ in the project root is read automatically (see `.env.example`).
name for details (main PID, start time, restarts, recent journal lines) and name for details (main PID, start time, restarts, recent journal lines) and
run `start` / `stop` / `restart` / `enable` / `disable` actions. run `start` / `stop` / `restart` / `enable` / `disable` actions.
- **Plugins** — currently **llama.cpp**: model status, load/unload buttons and - **Plugins** — currently **llama.cpp**: model status, load/unload buttons and
a rescan for a `llama-server` running in router mode; and **sleep a rescan for a `llama-server` running in router mode; **sleep
inhibitors**: active block-mode `systemd-inhibit` locks with a verdict on inhibitors**: active block-mode `systemd-inhibit` locks with a verdict on
whether the machine may sleep right now, plus a switch that makes the whether the machine may sleep right now, plus a switch that makes the
dashboard itself hold a sleep lock (released again on shutdown). dashboard itself hold a sleep lock (released again on shutdown); and
**GPU power profiles** (LACT): the active profile per GPU is polled, and
each GPU gets a profile dropdown with a *set* and a *reload* button.
### llama.cpp router mode ### llama.cpp router mode
@ -101,6 +103,16 @@ The plugin polls `GET /health` and `GET /models`, posts `{"model": id}` to
If the server is down the plugin shows *unreachable* and the rest of the If the server is down the plugin shows *unreachable* and the rest of the
dashboard keeps working. dashboard keeps working.
### GPU power profiles (LACT)
The plugin shells out to `lact cli` (`lact` must be in PATH). It lists the
GPUs with `lact cli list`, polls the active profile of every GPU with
`lact cli --gpu-id <id> profile get`, and applies a selected profile with
`lact cli --gpu-id <id> profile set <name>`. The per-GPU profile dropdowns
are *not* refreshed automatically (LACT auto-switching can change the active
profile behind the scenes, which the polled badge picks up); use the per-GPU
*reload* button to refresh them.
## Running as a systemd service ## Running as a systemd service
A ready-made unit is in [`deploy/dashboard.service`](deploy/dashboard.service): A ready-made unit is in [`deploy/dashboard.service`](deploy/dashboard.service):
@ -130,7 +142,7 @@ app/
journal.py # journalctl -o export parser + cursors journal.py # journalctl -o export parser + cursors
render.py # jinja env + filters render.py # jinja env + filters
routers/ # overview / disks / processes / journal / services / plugins routers/ # overview / disks / processes / journal / services / plugins
plugins/ # base.Plugin + llamacpp + sleep plugins plugins/ # base.Plugin + llamacpp + sleep + lact plugins
templates/ # htmx fragments templates/ # htmx fragments
static/ # css, js, vendored htmx + chart.js static/ # css, js, vendored htmx + chart.js
deploy/ # dashboard.service deploy/ # dashboard.service

View file

@ -24,6 +24,10 @@ def _shorten(name: str) -> str:
series = groups[-1].split(" / ")[0] series = groups[-1].split(" / ")[0]
model = name.split("]", 1)[1].split("[", 1)[0].strip() model = name.split("]", 1)[1].split("[", 1)[0].strip()
return f"{brand} {model} ({series})".strip() return f"{brand} {model} ({series})".strip()
if len(groups) == 1:
series = groups[0].split(" / ")[0]
model = name.split("[", 1)[0].strip()
return f"{model} ({series})".strip()
return name[:50] return name[:50]

View file

@ -1,8 +1,9 @@
from app.plugins.base import Plugin from app.plugins.base import Plugin
from app.plugins.lact import plugin as lact_plugin, router as lact_router
from app.plugins.llamacpp import plugin as llamacpp_plugin, router as llamacpp_router from app.plugins.llamacpp import plugin as llamacpp_plugin, router as llamacpp_router
from app.plugins.sleep import plugin as sleep_plugin, router as sleep_router from app.plugins.sleep import plugin as sleep_plugin, router as sleep_router
PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin] PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin, lact_plugin]
ROUTERS = [llamacpp_router, sleep_router] ROUTERS = [llamacpp_router, sleep_router, lact_router]
__all__ = ["PLUGINS", "ROUTERS", "Plugin"] __all__ = ["PLUGINS", "ROUTERS", "Plugin"]

158
app/plugins/lact.py Normal file
View file

@ -0,0 +1,158 @@
import asyncio
import re
import time
from typing import Any
from fastapi import APIRouter, Form
from fastapi.responses import HTMLResponse
from app.collect.gpu import _shorten
from app.plugins.base import Plugin
from app.render import render
router = APIRouter(prefix="/api/plugins/lact", tags=["plugins"])
LIST_TIMEOUT = 5
SET_TIMEOUT = 15
GPU_CACHE_TTL = 60
_gpu_cache: tuple[float, list[dict[str, str]]] | None = None
_set_lock = asyncio.Lock()
async def _run(args: list[str], timeout: float) -> tuple[str, str]:
try:
proc = await asyncio.create_subprocess_exec(
"lact", "cli", *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return "", "lact not found in PATH"
except OSError as e:
return "", str(e)[:200]
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
return "", "lact timed out"
if proc.returncode != 0:
return "", (err.decode(errors="replace").strip() or f"lact failed (rc={proc.returncode})")[:200]
return out.decode(errors="replace"), ""
def _parse_gpus(out: str) -> list[dict[str, str]]:
gpus: list[dict[str, str]] = []
for line in out.splitlines():
m = re.match(r"^\s*(\d+):\s+(.*)$", line)
if not m:
continue
rest = m.group(2)
name = re.search(r"\(([^)]*)\)", rest)
gpu_type = re.search(r"\[([^\]]*)\]\s*$", rest)
gpus.append({
"id": m.group(1),
"name": _shorten(name.group(1)) if name else rest.strip(),
"type": gpu_type.group(1) if gpu_type else "",
})
return gpus
async def _gpus(force: bool = False) -> tuple[list[dict[str, str]], str]:
global _gpu_cache
if not force and _gpu_cache is not None:
ts, cached = _gpu_cache
if time.monotonic() - ts < GPU_CACHE_TTL:
return [dict(g) for g in cached], ""
out, err = await _run(["list"], LIST_TIMEOUT)
if err:
return [], err
gpus = _parse_gpus(out)
_gpu_cache = (time.monotonic(), gpus)
return gpus, ""
async def _gpu_entry(g: dict[str, str], with_profiles: bool) -> dict[str, Any]:
entry: dict[str, Any] = {**g, "profiles": [], "active": None, "error": ""}
base = ["--gpu-id", g["id"], "profile"]
if with_profiles:
active_p, profiles_p = await asyncio.gather(
_run([*base, "get"], LIST_TIMEOUT),
_run([*base, "list"], LIST_TIMEOUT),
)
else:
active_p, profiles_p = await _run([*base, "get"], LIST_TIMEOUT), None
out, err = active_p
if err:
entry["error"] = err
return entry
entry["active"] = out.strip() or None
if profiles_p is not None:
out2, err2 = profiles_p
entry["error"] = err2
if not err2:
entry["profiles"] = [line.strip() for line in out2.splitlines() if line.strip()]
return entry
async def _gather(with_profiles: bool, force_gpus: bool = False) -> dict[str, Any]:
gpus, err = await _gpus(force=force_gpus)
if err:
return {"gpus": [], "error": err}
entries = await asyncio.gather(*[_gpu_entry(g, with_profiles) for g in gpus])
return {"gpus": list(entries), "error": ""}
async def _state(message: str = "", error: str = "") -> str:
data = await _gather(with_profiles=False)
data["message"] = message
data["error"] = error or data["error"]
return render("plugins/lact_state.html", **data)
async def _skeleton(message: str = "", error: str = "") -> str:
data = await _gather(with_profiles=True, force_gpus=True)
data["message"] = message
data["error"] = error or data["error"]
return render("plugins/lact_skeleton.html", **data)
@router.get("/state")
async def state():
return HTMLResponse(await _state())
@router.post("/set")
async def set_profile(gpu_id: str = Form(...), profile: str = Form(...)):
async with _set_lock:
data = await _gather(with_profiles=True, force_gpus=True)
if data["error"]:
return HTMLResponse(await _skeleton(error=data["error"]))
gpu = next((g for g in data["gpus"] if g["id"] == gpu_id), None)
if gpu is None:
return HTMLResponse(await _skeleton(error=f"unknown gpu id: {gpu_id}"))
if profile not in gpu["profiles"]:
return HTMLResponse(await _skeleton(error=f"unknown profile: {profile}"))
if profile == gpu["active"]:
return HTMLResponse(await _skeleton(message=f"{gpu['name']}: {profile} already active"))
_out, err = await _run(["--gpu-id", gpu_id, "profile", "set", profile], SET_TIMEOUT)
if err:
return HTMLResponse(await _skeleton(error=err))
return HTMLResponse(await _skeleton(message=f"{gpu['name']}: set profile {profile}"))
@router.post("/reload")
async def reload(gpu_id: str | None = Form(None)):
_ = gpu_id
return HTMLResponse(await _skeleton(message="profiles refreshed"))
plugin = Plugin(
id="lact",
title="GPU power profiles",
description="Active profile per GPU and a profile switcher (lact cli).",
skeleton_fn=_skeleton,
)

File diff suppressed because it is too large Load diff

View file

@ -240,6 +240,7 @@ button:hover { border-color: var(--accent); }
.badge-sleeping { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); } .badge-sleeping { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); }
.badge-failed { color: #ffcdd2; border-color: rgba(239, 83, 80, .6); background: rgba(239, 83, 80, .12); } .badge-failed { color: #ffcdd2; border-color: rgba(239, 83, 80, .6); background: rgba(239, 83, 80, .12); }
.badge-block { color: #ffcdd2; border-color: rgba(239, 83, 80, .6); background: rgba(239, 83, 80, .12); } .badge-block { color: #ffcdd2; border-color: rgba(239, 83, 80, .6); background: rgba(239, 83, 80, .12); }
.badge-active { color: #c8e6c9; border-color: rgba(102, 187, 106, .6); background: rgba(102, 187, 106, .12); }
.badge-downloading { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); } .badge-downloading { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); }
.llama-id { overflow: hidden; text-overflow: ellipsis; } .llama-id { overflow: hidden; text-overflow: ellipsis; }
.llama-health { .llama-health {
@ -267,8 +268,10 @@ button:hover { border-color: var(--accent); }
} }
.llama-model .llama-id { flex: 1 1 auto; min-width: 80px; } .llama-model .llama-id { flex: 1 1 auto; min-width: 80px; }
.llama-model-actions { display: flex; gap: 6px; margin-left: auto; } .llama-model-actions { display: flex; gap: 6px; margin-left: auto; }
.llama-load { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; } .llama-load, .lact-load { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.llama-load select { max-width: 420px; } .llama-load select, .lact-load select { max-width: 420px; }
.lact-gpu { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.lact-gpu-name { font-weight: 600; }
.part-mounts { max-width: 420px; overflow: hidden; text-overflow: ellipsis; } .part-mounts { max-width: 420px; overflow: hidden; text-overflow: ellipsis; }
.part-usage { margin-left: auto; } .part-usage { margin-left: auto; }
.inh-own { background: rgba(79, 195, 247, .08); } .inh-own { background: rgba(79, 195, 247, .08); }

View file

@ -0,0 +1,22 @@
<div class="lact">
<div id="lact-state" hx-get="/api/plugins/lact/state" hx-trigger="every 5s" hx-swap="innerHTML">
{% include "plugins/lact_state.html" %}
</div>
{% if gpus and not error %}
{% for g in gpus %}
<div class="lact-load">
<select id="lact-pick-{{ g.id }}" name="profile">
{% for p in g.profiles %}
<option value="{{ p }}" {{ "selected" if p == g.active }}>{{ p }}{% if p == g.active %} (active){% endif %}</option>
{% else %}
<option value="" disabled selected>no profiles</option>
{% endfor %}
</select>
<span class="actions">
<button class="btn" hx-post="/api/plugins/lact/set" hx-vals='{"gpu_id":"{{ g.id }}"}' hx-include="#lact-pick-{{ g.id }}" hx-target="closest .plugin-body" hx-swap="innerHTML">set</button>
<button class="btn" hx-post="/api/plugins/lact/reload" hx-vals='{"gpu_id":"{{ g.id }}"}' hx-target="closest .plugin-body" hx-swap="innerHTML">reload</button>
</span>
</div>
{% endfor %}
{% endif %}
</div>

View file

@ -0,0 +1,20 @@
{% if error %}<div class="alert">lact: {{ error }}</div>{% endif %}
{% if message %}<div class="notice">{{ message }}</div>{% endif %}
{% if not error %}
{% for g in gpus %}
<div class="lact-gpu">
<span class="dot {{ "dot-failed" if g.error else "dot-run" }}"></span>
<span class="lact-gpu-name">{{ g.name }}</span>
{% if g.type %}<span class="muted small">{{ g.type }}</span>{% endif %}
{% if g.error %}
<span class="muted small">{{ g.error }}</span>
{% elif g.active %}
<span class="badge badge-active">{{ g.active }}</span>
{% else %}
<span class="muted small">no profile</span>
{% endif %}
</div>
{% else %}
<div class="muted small">no GPUs reported by lact</div>
{% endfor %}
{% endif %}