Implement GPU Profile switcher plugin
This commit is contained in:
parent
14f44f20fe
commit
fbd4b6c5e8
9 changed files with 3961 additions and 9 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
158
app/plugins/lact.py
Normal 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,
|
||||||
|
)
|
||||||
3728
opencode/opencode_session_lact_plugin_2026-08-30.txt
Normal file
3728
opencode/opencode_session_lact_plugin_2026-08-30.txt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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); }
|
||||||
|
|
|
||||||
22
templates/plugins/lact_skeleton.html
Normal file
22
templates/plugins/lact_skeleton.html
Normal 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>
|
||||||
20
templates/plugins/lact_state.html
Normal file
20
templates/plugins/lact_state.html
Normal 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 %}
|
||||||
Loading…
Reference in a new issue