From fbd4b6c5e8d7efc259bbc00aada9f9bdca36b135 Mon Sep 17 00:00:00 2001 From: Johannes Schriewer Date: Sun, 30 Aug 2026 22:20:24 +0200 Subject: [PATCH] Implement GPU Profile switcher plugin --- AGENTS.md | 8 +- README.md | 18 +- app/collect/gpu.py | 4 + app/plugins/__init__.py | 5 +- app/plugins/lact.py | 158 + ...pencode_session_lact_plugin_2026-08-30.txt | 3728 +++++++++++++++++ static/css/style.css | 7 +- templates/plugins/lact_skeleton.html | 22 + templates/plugins/lact_state.html | 20 + 9 files changed, 3961 insertions(+), 9 deletions(-) create mode 100644 app/plugins/lact.py create mode 100644 opencode/opencode_session_lact_plugin_2026-08-30.txt create mode 100644 templates/plugins/lact_skeleton.html create mode 100644 templates/plugins/lact_state.html diff --git a/AGENTS.md b/AGENTS.md index 23c39aa..e1c6e85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 browser page. Binds to `127.0.0.1:8501`, no auth by design (see the README 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 @@ -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` on port 8080) + sleep plugin (lists block-mode `systemd-inhibit` locks; 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 diff --git a/README.md b/README.md index c9551b3..7e81acb 100644 --- a/README.md +++ b/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 run `start` / `stop` / `restart` / `enable` / `disable` actions. - **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 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 @@ -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 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 profile get`, and applies a selected profile with +`lact cli --gpu-id profile set `. 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 A ready-made unit is in [`deploy/dashboard.service`](deploy/dashboard.service): @@ -130,7 +142,7 @@ app/ journal.py # journalctl -o export parser + cursors render.py # jinja env + filters routers/ # overview / disks / processes / journal / services / plugins - plugins/ # base.Plugin + llamacpp + sleep plugins + plugins/ # base.Plugin + llamacpp + sleep + lact plugins templates/ # htmx fragments static/ # css, js, vendored htmx + chart.js deploy/ # dashboard.service diff --git a/app/collect/gpu.py b/app/collect/gpu.py index 479e8e6..f44cc77 100644 --- a/app/collect/gpu.py +++ b/app/collect/gpu.py @@ -24,6 +24,10 @@ def _shorten(name: str) -> str: series = groups[-1].split(" / ")[0] model = name.split("]", 1)[1].split("[", 1)[0].strip() return f"{brand} {model} ({series})".strip() + if len(groups) == 1: + series = groups[0].split(" / ")[0] + model = name.split("[", 1)[0].strip() + return f"{model} ({series})".strip() return name[:50] diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index e0b76d7..ebdc5ad 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -1,8 +1,9 @@ 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.sleep import plugin as sleep_plugin, router as sleep_router -PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin] -ROUTERS = [llamacpp_router, sleep_router] +PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin, lact_plugin] +ROUTERS = [llamacpp_router, sleep_router, lact_router] __all__ = ["PLUGINS", "ROUTERS", "Plugin"] diff --git a/app/plugins/lact.py b/app/plugins/lact.py new file mode 100644 index 0000000..32ea1c5 --- /dev/null +++ b/app/plugins/lact.py @@ -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, +) diff --git a/opencode/opencode_session_lact_plugin_2026-08-30.txt b/opencode/opencode_session_lact_plugin_2026-08-30.txt new file mode 100644 index 0000000..ca4db39 --- /dev/null +++ b/opencode/opencode_session_lact_plugin_2026-08-30.txt @@ -0,0 +1,3728 @@ +mac 2026-08-29 22:20:00 → now 1 session(s) + +Session Started Where Title +FUBB6l 2026-08-30 21:44 Code/dashboard LACT GPU profile display and switcher + + + ⚑ ❬FUBB6l❭ LACT GPU profile display and switcher ~/Code/dashboard +──────────────────────────────────────────────────────────────────────────────────────────────────── + + │ 2026-08-30 21:46:39 • user + │ + │ We're implementing a new plugin: LACT graphics card profile display and switcher. + │ + │ - You can get the available profiles with `lact cli profile list` + │ - fetch the currently active profile with `lact cli profile get` + │ - set profile with `lact cli profile set ` + │ + │ All commands have a parameter `--gpu-id ` to select a GPU. The parameter comes as first + │ parameter before commands. + │ Get a list of available GPUs with `lact cli list`. + │ Shorten the GPU names like on the overview card. + │ + │ The list of profiles should be a dropdown per GPU and have a reload button. Do not refresh the + │ profiles automatically (see llama plugin model load dropdown for example) + + + │ 2026-08-30 21:46:39 • assistant • Qwen3.8-27B-Think @plan + │ + │ Let me start by understanding the codebase structure, and then investigate how existing plugins + │ (especially the llama plugin) are implemented, and after that, I'll make a plan for the LACT + │ plugin. + │ + │ Let me take a look at the codebase. + │ + │ I'll explore the codebase to understand the plugin architecture before planning the LACT plugin. + │ + │ read /home/mac/Code/dashboard/app/plugins/base.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/plugins/base.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/plugins/base.py + │ file + │ + │ 1: from collections.abc import Awaitable, Callable + │ 2: from dataclasses import dataclass, field + │ 3: + │ 4: + │ 5: @dataclass + │ 6: class Plugin: + │ 7: id: str + │ 8: title: str + │ 9: description: str = "" + │ 10: skeleton_fn: Callable[[], Awaitable[str]] | None = field(default=None) + │ 11: open_fn: Callable[[], Awaitable[None]] | None = field(default=None) + │ 12: close_fn: Callable[[], Awaitable[None]] | None = field(default=None) + │ 13: + │ 14: async def skeleton(self) -> str: + │ 15: if self.skeleton_fn is None: + │ 16: raise NotImplementedError + │ 17: return await self.skeleton_fn() + │ 18: + │ 19: async def open(self) -> None: + │ 20: if self.open_fn is not None: + │ 21: await self.open_fn() + │ 22: + │ 23: async def close(self) -> None: + │ 24: if self.close_fn is not None: + │ 25: await self.close_fn() + │ + │ (End of file - total 25 lines) + │ + │ glob app/plugins/**/*.py ok 0.0s + │ in + │ { + │ "pattern": "app/plugins/**/*.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/plugins/sleep.py + │ /home/mac/Code/dashboard/app/plugins/base.py + │ /home/mac/Code/dashboard/app/plugins/__init__.py + │ /home/mac/Code/dashboard/app/plugins/llamacpp.py + │ glob app/plugins/**/*.html ok 0.0s + │ in + │ { + │ "pattern": "app/plugins/**/*.html" + │ } + │ out No files found + │ + │ tokens 525 in 164 out 8899 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:46:44 • assistant • Qwen3.8-27B-Think @plan + │ + │ Let me read the plugins' `__init__.py` and the llamacpp.py plugin, and also check how the + │ templates are structured. + │ + │ read /home/mac/Code/dashboard/app/plugins/__init__.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/plugins/__init__.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/plugins/__init__.py + │ file + │ + │ 1: from app.plugins.base import Plugin + │ 2: from app.plugins.llamacpp import plugin as llamacpp_plugin, router as llamacpp_router + │ 3: from app.plugins.sleep import plugin as sleep_plugin, router as sleep_router + │ 4: + │ 5: PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin] + │ 6: ROUTERS = [llamacpp_router, sleep_router] + │ 7: + │ 8: __all__ = ["PLUGINS", "ROUTERS", "Plugin"] + │ + │ (End of file - total 8 lines) + │ + │ read /home/mac/Code/dashboard/app/plugins/llamacpp.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/plugins/llamacpp.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/plugins/llamacpp.py + │ file + │ + │ 1: from typing import Any + │ 2: + │ 3: import httpx + │ 4: from fastapi import APIRouter, Form + │ 5: from fastapi.responses import HTMLResponse + │ 6: + │ 7: from app.config import Settings, get_settings + │ 8: from app.plugins.base import Plugin + │ 9: from app.render import render + │ 10: + │ 11: router = APIRouter(prefix="/api/plugins/llamacpp", tags=["plugins"]) + │ 12: + │ 13: + │ 14: def _headers(settings: Settings) -> dict[str, str]: + │ 15: h = {} + │ 16: if settings.llama_api_key: + │ 17: h["Authorization"] = f"Bearer {settings.llama_api_key}" + │ 18: return h + │ 19: + │ 20: + │ 21: def _client() -> httpx.AsyncClient: + │ 22: settings = get_settings() + │ 23: return httpx.AsyncClient( + │ 24: base_url=settings.llama_base_url.rstrip("/"), + │ 25: timeout=settings.llama_timeout, + │ 26: headers=_headers(settings), + │ 27: ) + │ 28: + │ 29: + │ 30: async def gather_status() -> dict[str, Any]: + │ 31: """Query the llama-server router. Never raises; returns status dict.""" + │ 32: settings = get_settings() + │ 33: status: dict[str, Any] = { + │ 34: "base_url": settings.llama_base_url, + │ 35: "reachable": False, + │ 36: "health": None, + │ 37: "models": [], + │ 38: "error": None, + │ 39: } + │ 40: try: + │ 41: async with _client() as client: + │ 42: try: + │ 43: r = await client.get("/health") + │ 44: status["health"] = r.json().get("status") if r.status_code == 200 else + │ f"http {r.status_code}" + │ 45: except httpx.HTTPError: + │ 46: pass + │ 47: r = await client.get("/models") + │ 48: _ = r.raise_for_status() + │ 49: status["reachable"] = True + │ 50: data = r.json() + │ 51: for m in data.get("data", []): + │ 52: st: dict[str, Any] = m.get("status") or {} + │ 53: item: dict[str, str | bool | float] = { + │ 54: "id": str(m.get("id", "?")), + │ 55: "sta + │ … [4220 more chars] + │ + │ tokens 377 in 103 out 9587 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:46:46 • assistant • Qwen3.8-27B-Think @plan + │ + │ Next, let's take a look at the templates, the sleep plugin, and how the plugins are rendered in + │ the UI. Also, let's check how GPU names are shortened on the overview card. + │ + │ glob templates/**/*.html ok 0.0s + │ in + │ { + │ "pattern": "templates/**/*.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/plugins/sleep_state.html + │ /home/mac/Code/dashboard/templates/plugins/sleep_skeleton.html + │ /home/mac/Code/dashboard/templates/plugins/llamacpp_state.html + │ /home/mac/Code/dashboard/templates/plugins/llamacpp_skeleton.html + │ /home/mac/Code/dashboard/templates/plugins.html + │ /home/mac/Code/dashboard/templates/index.html + │ /home/mac/Code/dashboard/templates/overview.html + │ /home/mac/Code/dashboard/templates/service_detail.html + │ /home/mac/Code/dashboard/templates/services.html + │ /home/mac/Code/dashboard/templates/journal.html + │ /home/mac/Code/dashboard/templates/processes.html + │ /home/mac/Code/dashboard/templates/disks.html + │ read /home/mac/Code/dashboard/app/plugins/sleep.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/plugins/sleep.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/plugins/sleep.py + │ file + │ + │ 1: import asyncio + │ 2: import json + │ 3: import os + │ 4: import signal + │ 5: from typing import Any + │ 6: + │ 7: from fastapi import APIRouter, Form + │ 8: from fastapi.responses import HTMLResponse + │ 9: + │ 10: from app.plugins.base import Plugin + │ 11: from app.render import render + │ 12: + │ 13: router = APIRouter(prefix="/api/plugins/sleep", tags=["plugins"]) + │ 14: + │ 15: WHO = "Dashboard (sleep-inhibit)" + │ 16: WHY = "dashboard: keep system awake" + │ 17: BLOCK_MODES = ("block", "block-weak") + │ 18: + │ 19: _holder: asyncio.subprocess.Process | None = None + │ 20: _toggle_lock = asyncio.Lock() + │ 21: + │ 22: + │ 23: async def _list() -> tuple[list[dict[str, Any]], str]: + │ 24: try: + │ 25: proc = await asyncio.create_subprocess_exec( + │ 26: "systemd-inhibit", "--json=short", "--list", + │ 27: stdout=asyncio.subprocess.PIPE, + │ 28: stderr=asyncio.subprocess.PIPE, + │ 29: ) + │ 30: except OSError as e: + │ 31: return [], str(e)[:200] + │ 32: try: + │ 33: out, err = await asyncio.wait_for(proc.communicate(), 5) + │ 34: except TimeoutError: + │ 35: try: + │ 36: _ = proc.kill() + │ 37: except ProcessLookupError: + │ 38: pass + │ 39: return [], "systemd-inhibit timed out" + │ 40: if proc.returncode != 0: + │ 41: return [], (err.decode(errors="replace").strip() or f"systemd-inhibit failed + │ (rc={proc.returncode})")[:200] + │ 42: try: + │ 43: data = json.loads(out.decode(errors="replace")) + │ 44: except ValueError: + │ 45: return [], "could not parse systemd-inhibit output" + │ 46: if not isinstance(data, list): + │ 47: return [], "unexpected systemd-inhibit output" + │ 48: items: list[dict[str, Any]] = [e for e in data if isinstance(e, dict)] + │ 49: return items, "" + │ 50: + │ 51: + │ 52: def _verdict(inhibitors: list[dict[str, Any]]) -> str: + │ 53: for e in inhibitors: + │ 54: whats = str(e.get("what", "")).split(":") + │ 55: if "sleep" in whats and e.get("mod + │ … [4562 more chars] + │ + │ tokens 2209 in 105 out 10066 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:46:52 • assistant • Qwen3.8-27B-Think @plan + │ + │ Next, let's check the templates: the plugins tab, the state/skeleton templates for llamacpp and + │ sleep, the render function, and how GPU names are shortened in the overview. + │ + │ read /home/mac/Code/dashboard/templates/plugins.html ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/templates/plugins.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/plugins.html + │ file + │ + │ 1:
+ │ 2: {% for item in items %} + │ 3:
+ │ 4:

{{ item.p.title }}

+ │ 5:

{{ item.p.description }}

+ │ 6:
{{ item.body | safe }}
+ │ 7:
+ │ 8: {% else %} + │ 9:
no plugins registered
+ │ 10: {% endfor %} + │ 11:
+ │ + │ (End of file - total 11 lines) + │
+ │ read /home/mac/Code/dashboard/templates/plugins/llamacpp_state.html ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/templates/plugins/llamacpp_state.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/plugins/llamacpp_state.html + │ file + │ + │ 1: {% if not reachable %} + │ 2:
llama-server unreachable at {{ base_url }}{% + │ if error %}: {{ error }}{% endif %}
+ │ 3:
start it in router mode: llama-server + │ --models-dir /path/to/models
+ │ 4: {% else %} + │ 5:
+ │ 6: + │ 7: {{ "healthy" if health == "ok" else (health or "status + │ unknown") }} + │ 8: {{ base_url }} + │ 9:
+ │ 10: {% if message %}
{{ message }}
{% endif %} + │ 11: {% if error_msg %}
{{ error_msg }}
{% endif %} + │ 12: + │ 13:

Loaded models

+ │ 14: {% if loaded %} + │ 15:
+ │ 16: {% for m in loaded %} + │ 17:
+ │ 18: + │ 19: {{ m.id }} + │ 20: {{ m.state }}{% if + │ m.failed %} (exit {{ m.exit_code }}){% endif %} + │ 21: {% if m.progress is not none %}
{% endif %} + │ 22: + │ 23: + │ 24: + │ 25:
+ │ 26: {% endfor %} + │ 27:
+ │ 28: {% else %} + │ 29:
no models loaded
+ │ 30: {% endif %} + │ 31: {% endif %} + │ + │ (End of file - total 31 lines) + │
+ │ read /home/mac/Code/dashboard/templates/plugins/llamacpp_skeleton.html ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/templates/plugins/llamacpp_skeleton.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/plugins/llamacpp_skeleton.html + │ file + │ + │ 1:
+ │ 2:
+ │ 3: {% include "plugins/llamacpp_state.html" %} + │ 4:
+ │ 5: {% if reachable %} + │ 6:

Available models

+ │ 7: {% if available %} + │ 8:
+ │ 9: + │ 14: + │ 15:
+ │ 16: {% else %} + │ 17:
no unloaded models (is --models-dir set on the server?)
+ │ 18: {% endif %} + │ 19: + │ 20:
+ │ 21: + │ 22:
+ │ 23: {% endif %} + │ 24:
+ │ + │ (End of file - total 24 lines) + │
+ │ + │ tokens 2407 in 148 out 12379 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:46:58 • assistant • Qwen3.8-27B-Think @plan + │ + │ Next, let's check how the plugins router (the one that assembles the plugin tab) works, the + │ rendering, and also the shortening of GPU names in the overview. + │ + │ glob app/routers/*.py ok 0.0s + │ in + │ { + │ "pattern": "app/routers/*.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/routers/overview.py + │ /home/mac/Code/dashboard/app/routers/plugins.py + │ /home/mac/Code/dashboard/app/routers/__init__.py + │ /home/mac/Code/dashboard/app/routers/services.py + │ /home/mac/Code/dashboard/app/routers/journal.py + │ /home/mac/Code/dashboard/app/routers/processes.py + │ /home/mac/Code/dashboard/app/routers/disks.py + │ grep plugins ok 0.0s + │ in + │ { + │ "pattern": "plugins", + │ "path": "/home/mac/Code/dashboard/app/routers" + │ } + │ out + │ Found 6 matches + │ /home/mac/Code/dashboard/app/routers/plugins.py: + │ Line 4: from app.plugins import PLUGINS, Plugin + │ + │ Line 7: router = APIRouter(prefix="/api/plugins", tags=["plugins"]) + │ + │ Line 11: async def plugins_index(): + │ + │ Line 19: return HTMLResponse(render("plugins.html", items=items)) + │ + │ + │ /home/mac/Code/dashboard/app/routers/__init__.py: + │ Line 1: from app.routers import disks, journal, overview, plugins, processes, services + │ + │ Line 3: __all__ = ["overview", "disks", "processes", "journal", "services", "plugins"] + │ + │ tokens 1304 in 113 out 14933 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:47:03 • assistant • Qwen3.8-27B-Think @plan + │ + │ Let's take a look at the plugin's router, overview's router, and how the GPU name is abbreviated + │ on the overview card. Also, I'll check the sleep state/skeleton template for style reference. + │ + │ read /home/mac/Code/dashboard/app/routers/plugins.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/routers/plugins.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/routers/plugins.py + │ file + │ + │ 1: from fastapi import APIRouter + │ 2: from fastapi.responses import HTMLResponse + │ 3: + │ 4: from app.plugins import PLUGINS, Plugin + │ 5: from app.render import render + │ 6: + │ 7: router = APIRouter(prefix="/api/plugins", tags=["plugins"]) + │ 8: + │ 9: + │ 10: @router.get("") + │ 11: async def plugins_index(): + │ 12: items: list[dict[str, Plugin | str]] = [] + │ 13: for p in PLUGINS: + │ 14: try: + │ 15: body = await p.skeleton() + │ 16: except Exception as e: # noqa + │ 17: body = f"
plugin error: {e}
" + │ 18: items.append({"p": p, "body": body}) + │ 19: return HTMLResponse(render("plugins.html", items=items)) + │ + │ (End of file - total 19 lines) + │
+ │ read /home/mac/Code/dashboard/app/routers/overview.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/routers/overview.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/routers/overview.py + │ file + │ + │ 1: import asyncio + │ 2: import math + │ 3: import socket + │ 4: import time + │ 5: from dataclasses import fields + │ 6: from typing import Any + │ 7: + │ 8: import psutil + │ 9: from fastapi import APIRouter, Request + │ 10: from fastapi.responses import HTMLResponse, JSONResponse + │ 11: + │ 12: from app.collect import net as net_col + │ 13: from app.config import get_settings + │ 14: from app.render import render, uptime_str + │ 15: from app.sample import Sample + │ 16: + │ 17: router = APIRouter(prefix="/api", tags=["overview"]) + │ 18: + │ 19: RowAgg = dict[str, float | int | None] + │ 20: + │ 21: + │ 22: def _window(snap: list[Sample], max_points: int) -> list[tuple[float, dict[str, + │ RowAgg]]]: + │ 23: n = len(snap) + │ 24: w = max(1, math.ceil(n / max_points)) + │ 25: out: list[tuple[float, dict[str, RowAgg]]] = [] + │ 26: for start in range(0, n, w): + │ 27: chunk = snap[start : start + w] + │ 28: vals: dict[str, list[int | float]] = {} + │ 29: for sample in chunk: + │ 30: for f in fields(sample): + │ 31: if f.name == "ts": + │ 32: continue + │ 33: v = getattr(sample, f.name) + │ 34: if isinstance(v, (int, float)) and not isinstance(v, bool): + │ 35: vals.setdefault(f.name, []).append(v) + │ 36: row: dict[str, RowAgg] = {} + │ 37: for k, lst in vals.items(): + │ 38: ints = all(isinstance(v, int) for v in lst) + │ 39: avg = sum(lst) / len(lst) + │ 40: row[k] = { + │ 41: "avg": round(avg) if ints else round(avg, 1), + │ 42: "min": min(lst) if ints else round(min(lst), 1), + │ 43: "max": max(lst) if ints else round(max(lst), 1), + │ 44: } + │ 45: out.append((chunk[-1].ts, row)) + │ 46: return out + │ 47: + │ 48: + │ 49: @router.get("/overview") + │ 50: async def overview(request: Request): + │ 51: store = request.app.state.store + │ 52: s = store.latest() or Sample() + │ 53: mem_total = s.mem_total + │ … [1925 more chars] + │ read /home/mac/Code/dashboard/templates/plugins/sleep_state.html ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/templates/plugins/sleep_state.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/plugins/sleep_state.html + │ file + │ + │ 1: {% if error %}
systemd-inhibit failed: {{ error }}
{% endif %} + │ 2: {% if message %}
{{ message }}
{% endif %} + │ 3:
+ │ 4: + │ 5: {{ "sleep blocked" if verdict == "blocked" else "sleep + │ allowed" }} + │ 6: block-mode locks only — delay-mode locks are ignored + │ 7:
+ │ 8: + │ 12: {% if inhibitors %} + │ 13:
+ │ 14: + │ 15: + │ 16: + │ 17: {% for e in inhibitors %} + │ 18: + │ 19: + │ 20: + │ 21: + │ 22: + │ 23: + │ 24: {% endfor %} + │ 25: + │ 26:
whowhatwhymode
{{ e.who }}{% if e.proc %} {{ + │ e.proc }}{% endif %}{% for w in e.what.split(":") if w %}{{ w }} {% + │ endfor %}{{ e.why }}{{ e.mode + │ }}
+ │ 27:
+ │ 28: {% else %} + │ 29:
no active block-mode inhibitors — the system may sleep
+ │ 30: {% endif %} + │ + │ (End of file - total 30 lines) + │
+ │ + │ tokens 249 in 149 out 16350 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:47:07 • assistant • Qwen3.8-27B-Think @plan + │ + │ Next, I need to check how the GPU name is being shortened in the overview card. `s.gpu_name` — + │ let's take a look at the GPU collector. + │ + │ Now let me check how GPU names are gathered and shortened in the collector/overview: + │ + │ glob app/collect/*.py ok 0.0s + │ in + │ { + │ "pattern": "app/collect/*.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/collect/net.py + │ /home/mac/Code/dashboard/app/collect/power.py + │ /home/mac/Code/dashboard/app/collect/__init__.py + │ /home/mac/Code/dashboard/app/collect/mem.py + │ /home/mac/Code/dashboard/app/collect/procs.py + │ /home/mac/Code/dashboard/app/collect/gpu.py + │ /home/mac/Code/dashboard/app/collect/disks.py + │ /home/mac/Code/dashboard/app/collect/cpu.py + │ grep gpu_name ok 0.0s + │ in + │ { + │ "pattern": "gpu_name", + │ "path": "/home/mac/Code/dashboard" + │ } + │ out + │ Found 53 matches + │ /home/mac/Code/dashboard/app/sample.py: + │ Line 23: gpu_name: str = "no GPU detected" + │ + │ + │ /home/mac/Code/dashboard/opencode/opencode_session_refactor_sample_storage_2026-08-30.txt: + │ Line 289: │ 29: def _gpu_name() -> str: + │ + │ Line 1120: │ - `gpu_name` (str) + │ + │ Line 1394: │ `vram_pct: float | None`, `gpu_temp: float | None`, `gpu_name: str` + │ + │ Line 1458: │ | `gpu`, `vram_used/total`, `vram_pct`, `gpu_temp`, `gpu_name` | mixed | + │ `gpu.py` | + │ + │ Line 1689: │ float | None = None\n gpu_name: str = \"no GPU detected\"\n + │ battery: int | None = + │ + │ Line 1794: │ "gpu_name": _gpu_name(), + │ + │ Line 1860: │ else None,\n \"gpu_name\": _gpu_name(),\n }", + │ + │ Line 1901: │ float(temp),\n \"gpu_name\": name,\n }\n\n\ndef sample() -> + │ dict[str, Any]:\n + │ + │ Line 1904: │ \"vram_pct\": None,\n \"gpu_temp\": None,\n + │ \"gpu_name\": \"no GPU + │ + │ Line 2504: │ \"gpu\": s.get(\"gpu\"),\n \"gpu_name\": + │ s.get(\"gpu_name\"),\n + │ + │ Line 3377: │ - Notably, `ts`, `gpu_name`, `battery_status`, `ac_online` are correctly + │ EXCLUDED (ts + │ + │ + │ /home/mac/Code/dashboard/opencode/opencode_session_chart_flicker_2026-08-30.txt: + │ Line 128: │ 36: "gpu_name": s.get("gpu_name"), + │ + │ + │ /home/mac/Code/dashboard/app/routers/overview.py: + │ Line 69: "gpu_name": s.gpu_name, + │ + │ + │ /home/mac/Code/dashboard/app/collect/gpu.py: + │ Line 30: def _gpu_name() -> str: + │ + │ Line 80: s.gpu_name = _gpu_name() + │ + │ Line 123: s.gpu_name = name + │ + │ + │ /home/mac/Code/dashboard/opencode/opencode_session_2026-08-30.txt: + │ Line 1518: │ return f.read().strip()\n except OSError:\n return + │ None\n\n\ndef _gpu_name() -> + │ + │ Line 2246: │ \"gpu_name\": s.get(\"gpu_name\"),\n \"gpu_temp\": + │ s.get(\"gpu_temp\"),\n + │ + │ Line 2343: │
{{ c.gpu_name }}{% if c.gpu_temp is not none %} + │ · {{ c.gpu_temp | + │ + │ Line 2347: │
{{ c.gpu_name + │ … [2956 more chars] + │ + │ tokens 2194 in 127 out 16747 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:47:13 • assistant • Qwen3.8-27B-Think @plan + │ + │ The `opencode/` directory contains session logs, which are not part of the codebase. Let me read + │ the GPU collector and check `_gpu_name` and the shortening logic. + │ + │ read /home/mac/Code/dashboard/app/collect/gpu.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/collect/gpu.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/collect/gpu.py + │ file + │ + │ 1: import glob + │ 2: import re + │ 3: import shutil + │ 4: import subprocess + │ 5: + │ 6: from app.sample import Sample + │ 7: + │ 8: _name_cache: str | None = None + │ 9: + │ 10: + │ 11: def _read(path: str) -> str | None: + │ 12: try: + │ 13: with open(path) as f: + │ 14: return f.read().strip() + │ 15: except OSError: + │ 16: return None + │ 17: + │ 18: + │ 19: def _shorten(name: str) -> str: + │ 20: name = re.sub(r"\s*\(rev.*\)$", "", name).strip() + │ 21: groups = re.findall(r"\[([^\]]+)\]", name) + │ 22: if len(groups) >= 2: + │ 23: brand = groups[0] + │ 24: series = groups[-1].split(" / ")[0] + │ 25: model = name.split("]", 1)[1].split("[", 1)[0].strip() + │ 26: return f"{brand} {model} ({series})".strip() + │ 27: return name[:50] + │ 28: + │ 29: + │ 30: def _gpu_name() -> str: + │ 31: global _name_cache + │ 32: if _name_cache is None: + │ 33: _name_cache = "GPU" + │ 34: if shutil.which("lspci"): + │ 35: try: + │ 36: out = subprocess.run( + │ 37: ["lspci"], capture_output=True, text=True, timeout=5, check=False + │ 38: ).stdout + │ 39: for line in out.splitlines(): + │ 40: if "VGA" in line or "3D controller" in line: + │ 41: _name_cache = _shorten(line.split(":", 2)[-1].strip()) + │ 42: break + │ 43: except (OSError, subprocess.SubprocessError): + │ 44: pass + │ 45: return _name_cache + │ 46: + │ 47: + │ 48: def _amd(s: Sample) -> bool: + │ 49: devices = sorted(glob.glob("/sys/class/drm/card[0-9]*/device/gpu_busy_percent")) + │ 50: if not devices: + │ 51: return False + │ 52: busy_sum = 0 + │ 53: count = 0 + │ 54: vram_used = 0 + │ 55: vram_total = 0 + │ 56: temps: list[float] = [] + │ 57: for busy_path in devices: + │ 58: dev = busy_path.rsplit("/", 1)[0] + │ 59: try: + │ 60: busy_sum += int(_read(busy_path) or 0) + │ 61: count += 1 + │ 62: + │ … [2442 more chars] + │ + │ tokens 1926 in 76 out 19067 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:47:18 • assistant • Qwen3.8-27B-Think @plan + │ + │ Alright, now I understand the GPU name shortening: `_shorten()` in `app/collect/gpu.py` strips + │ the "(rev ...)" suffix and converts `Brand [model] [series / ...]` → `Brand model (series)`. + │ + │ Now, the LACT plugin: + │ - `lact cli list` lists the GPUs. I need to verify what the output format looks like. Let me + │ check whether `lact` is installed in this environment. + │ - `lact cli --gpu-id profile list` — lists the profiles + │ - `lact cli --gpu-id profile get` — the currently active profile + │ - `lact cli --gpu-id profile set ` + │ + │ The user is saying that the `--gpu-id` parameter "comes as the first parameter before the + │ command." In other words, the command becomes `lact cli --gpu-id profile list` and so on. + │ + │ Let me check the actual output format of `lact` on this machine. I can run read-only commands to + │ inspect the output — since it's read-only, it should be fine even in plan mode. `lact cli list` + │ just lists GPUs, so it's read-only. + │ + │ Also, let me check the render function, the main.py where the plugin's open/close is called, and + │ the config. + │ + │ Let me check the render function, main.py, and inspect the actual `lact` CLI output formats: + │ + │ read /home/mac/Code/dashboard/app/render.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/render.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/render.py + │ file + │ + │ 1: from datetime import timedelta + │ 2: from pathlib import Path + │ 3: from typing import Any + │ 4: + │ 5: from jinja2 import Environment, FileSystemLoader, select_autoescape + │ 6: + │ 7: BASE = Path(__file__).resolve().parent.parent + │ 8: + │ 9: + │ 10: def humanize(value: float | str | None) -> str: + │ 11: if value is None: + │ 12: return "—" + │ 13: n = float(value) + │ 14: for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + │ 15: if abs(n) < 1024 or unit == "TiB": + │ 16: if unit == "B": + │ 17: return f"{int(n)} B" + │ 18: return f"{n:.1f} {unit}" + │ 19: n /= 1024 + │ 20: return f"{n:.1f} TiB" + │ 21: + │ 22: + │ 23: def rate(value: float | str | None) -> str: + │ 24: if value is None: + │ 25: return "—" + │ 26: n = float(value) + │ 27: for unit in ("B/s", "KiB/s", "MiB/s", "GiB/s"): + │ 28: if abs(n) < 1024 or unit == "GiB/s": + │ 29: if unit == "B/s": + │ 30: return f"{int(n)} B/s" + │ 31: return f"{n:.1f} {unit}" + │ 32: n /= 1024 + │ 33: return f"{n:.1f} GiB/s" + │ 34: + │ 35: + │ 36: def uptime_str(seconds: float | None) -> str: + │ 37: if seconds is None: + │ 38: return "—" + │ 39: td = timedelta(seconds=int(seconds)) + │ 40: days, rem = divmod(td.seconds, 86400) + │ 41: hours, rem = divmod(rem, 3600) + │ 42: minutes = rem // 60 + │ 43: parts: list[str] = [] + │ 44: if days: + │ 45: parts.append(f"{days}d") + │ 46: if days or hours: + │ 47: parts.append(f"{hours}h") + │ 48: parts.append(f"{minutes}m") + │ 49: return " ".join(parts) + │ 50: + │ 51: + │ 52: def pct(value: float | None) -> str: + │ 53: if value is None: + │ 54: return "—" + │ 55: return f"{value:.0f}%" + │ 56: + │ 57: + │ 58: env = Environment( + │ 59: loader=FileSystemLoader(BASE / "templates"), + │ 60: autoescape=select_autoescape(("html", "j2")), + │ 61: ) + │ 62: env.filters["humanize"] = humanize + │ 63: env.filters["rate"] = rate + │ 64: env.filters["uptime"] = uptime_str + │ 65: env.filters["pct"] + │ … [162 more chars] + │ read /home/mac/Code/dashboard/main.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/main.py" + │ } + │ out + │ /home/mac/Code/dashboard/main.py + │ file + │ + │ 1: import uvicorn + │ 2: + │ 3: from app.config import get_settings + │ 4: + │ 5: if __name__ == "__main__": + │ 6: s = get_settings() + │ 7: uvicorn.run("app.main:app", host=s.host, port=s.port, log_level="warning") + │ + │ (End of file - total 7 lines) + │ + │ bash which lact && lact cli list 2>&1 | head -40 ok 0.0s + │ in + │ { + │ "command": "which lact && lact cli list 2>&1 | head -40" + │ } + │ out + │ /usr/bin/lact + │ 0: 1002:1636-17AA:380D-0000:04:00.0 (Renoir [Radeon Vega Series / Radeon Vega Mobile + │ Series]) [Integrated] + │ + │ tokens 1592 in 415 out 21069 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:47:30 • assistant • Qwen3.8-27B-Think @plan + │ + │ The output of `lact cli list` is as follows: + │ ``` + │ 0: 1002:1636-17AA:380D-0000:04:00.0 (Renoir [Radeon Vega Series / Radeon Vega Mobile Series]) + │ [Integrated] + │ ``` + │ + │ So the format is: `: ( [series]) []`. Let me parse this: + │ - GPU ID: `0` + │ - PCI: `1002:1636-17AA:380D-0000:04:00.0` + │ - Name part: `Renoir [Radeon Vega Series / Radeon Vega Mobile Series]` + │ - Type: `[Integrated]` + │ + │ The overview card shortens `AMD/ATI ... (Renoir [Radeon Vega Series])` into, for example, + │ `Renoir (Radeon Vega Series)`. Let me check the output of lspci on this machine and see what the + │ overview displays. + │ + │ Actually, the output format of lspci is: `04:00.0 VGA compatible controller: AMD/ATI Device + │ [Renoir] [Radeon Vega Series / Radeon Vega Mobile Series] (rev xx)`. And `_shorten` produces + │ `AMD/ATI Renoir (Radeon Vega Series)`. Hmm, wait: brand = groups[0] = "AMD/ATI"? No wait — the + │ groups are `[Renoir]` and `[Radeon Vega Series / Radeon Vega Mobile Series]`, so + │ brand=groups[0]="Renoir", series="Radeon Vega Series", model = + │ name.split("]",1)[1].split("[",1)[0].strip() — hmm. + │ + │ Let me actually check what lspci displays on this machine. + │ + │ Let me check what the GPU card currently shows. Let me run `lspci | grep -E "VGA|3D + │ controller"`. + │ + │ Next, `lact cli profile list` for GPU 0. And `lact cli profile get`. Let me run these read-only + │ commands. + │ + │ bash lspci 2>/dev/null | grep -E "VGA|3D controller" ok 0.0s + │ in + │ { + │ "command": "lspci 2>/dev/null | grep -E \"VGA|3D controller\"" + │ } + │ out 04:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Renoir [Radeon + │ Vega Series / Radeon Vega Mobile Series] (rev c1) + │ bash lact cli --gpu-id 0 profile list 2>&1; echo "---rc=$?"; lact cli --gpu-id 0 … ok 0.0s + │ in + │ { + │ "command": "lact cli --gpu-id 0 profile list 2>&1; echo \"---rc=$?\"; lact cli --gpu-id 0 + │ profile get 2>&1; echo \"---rc=$?\"" + │ } + │ out + │ Default + │ ---rc=0 + │ Default + │ ---rc=0 + │ + │ tokens 1014 in 564 out 23075 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:47:45 • assistant • Qwen3.8-27B-Think @plan + │ + │ So: + │ - lspci line: `04:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Renoir + │ [Radeon Vega Series / Radeon Vega Mobile Series] (rev c1)` + │ - After `_shorten`: groups = ["AMD/ATI", "Radeon Vega Series / Radeon Vega Mobile Series"], + │ brand = "AMD/ATI", series = "Radeon Vega Series", model = + │ name.split("]",1)[1].split("[",1)[0].strip() — splitting "Advanced Micro Devices, Inc. + │ [AMD/ATI] Renoir [Radeon Vega Series...]" by "]" → "Advanced Micro Devices, Inc. [AMD/ATI" + │ then splitting by "[" → " Renoir " → "Renoir". So the result is: `AMD/ATI Renoir (Radeon Vega + │ Series)`. + │ + │ Wait, but the earlier grep result from the session log showed `gpu_name: "AMD/ATI Renoir (Radeon + │ Vega Series)"`. Good. + │ + │ - `lact cli list` output: `0: 1002:1636-17AA:380D-0000:04:00.0 (Renoir [Radeon Vega Series / + │ Radeon Vega Mobile Series]) [Integrated]` + │ - `lact cli --gpu-id 0 profile list`: `Default` (one profile per line? or one profile per line?) + │ - `lact cli --gpu-id 0 profile get`: `Default` + │ + │ Let me check the lact CLI documentation for the output format of profile list. I'll check `lact + │ cli profile list --help` and `lact cli --help`. Also, let me check how profile list output looks + │ when there are multiple profiles — probably one per line. + │ + │ Let me check the help. + │ + │ bash lact cli --help 2>&1; echo ===; lact cli profile --help 2>&1; echo ===; lact… ok 0.0s + │ in + │ { + │ "command": "lact cli --help 2>&1; echo ===; lact cli profile --help 2>&1; echo ===; lact + │ cli profile list --help 2>&1" + │ } + │ out + │ Run the CLI + │ + │ Usage: lact cli [OPTIONS] + │ + │ Commands: + │ list List GPUs + │ info Show GPU info + │ stats Show GPU stats + │ snapshot Generate debug snapshot + │ power-limit Manage GPU power limit + │ profile Manage profiles + │ detach Detach the GPU from LACT (temporarily ignore it) + │ reattach Reattach a previously detached GPU + │ help Print this message or the help of the given subcommand(s) + │ + │ Options: + │ -g, --gpu-id + │ -h, --help Print help + │ -V, --version Print version + │ === + │ Manage profiles + │ + │ Usage: lact cli profile [COMMAND] + │ + │ Commands: + │ list List profiles + │ get Current profile + │ set Set profile + │ auto-switch Manage profile auto switching + │ help Print this message or the help of the given subcommand(s) + │ + │ Options: + │ -h, --help Print help + │ === + │ List profiles + │ + │ Usage: lact cli profile list + │ + │ Options: + │ -h, --help Print help + │ bash lact cli list --help 2>&1; echo ===; lact --version 2>&1 ok 0.0s + │ in + │ { + │ "command": "lact cli list --help 2>&1; echo ===; lact --version 2>&1" + │ } + │ out + │ List GPUs + │ + │ Usage: lact cli list + │ + │ Options: + │ -h, --help Print help + │ === + │ error: unexpected argument '--version' found + │ + │ Usage: lact [COMMAND] + │ + │ For more information, try '--help'. + │ + │ tokens 73 in 496 out 24653 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:47:57 • assistant • Qwen3.8-27B-Think @plan + │ + │ Now I understand the CLI. The output format of `lact cli list` is as follows: + │ ``` + │ 0: 1002:1636-17AA:380D-0000:04:00.0 (Renoir [Radeon Vega Series / Radeon Vega Mobile Series]) + │ [Integrated] + │ ``` + │ + │ Let's analyze the format: + │ - `0` = GPU ID + │ - `1002:1636-17AA:380D-0000:04:00.0` = PCI address / device identifier + │ - `(Renoir [Radeon Vega Series / Radeon Vega Mobile Series])` = GPU name in parentheses + │ - `[Integrated]` = type badge + │ + │ The user's request is: "Shorten the GPU names in the same way as the overview card." + │ + │ On the overview card, `_shorten` is used. Let's see how the name appears on the overview card. + │ The `gpu_name` field is set from `_gpu_name()`, which reads the output of lspci and applies + │ `_shorten` to it. + │ + │ The lspci output here is: + │ `04:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Renoir [Radeon Vega + │ Series / Radeon Vega Mobile Series] (rev c1)` + │ + │ So `_gpu_name()` extracts `line.split(":", 2)[-1].strip()`, i.e. `Advanced Micro Devices, Inc. + │ [AMD/ATI] Renoir [Radeon Vega Series / Radeon Vega Mobile Series] (rev c1)`, and then applies + │ `_shorten` to it. + │ + │ `_shorten`: + │ - Strips ` (rev c1)` + │ - groups = findall of `[...]` = `["AMD/ATI", "Radeon Vega Series / Radeon Vega Mobile Series"]` + │ - len(groups) >= 2 → brand = "AMD/ATI", series = "Radeon Vega Series" (the first " / " segment + │ of the last group), model = `name.split("]", 1)[1].split("[", 1)[0].strip()` + │ + │ Let's compute: after stripping rev, name = `Advanced Micro Devices, Inc. [AMD/ATI] Renoir + │ [Radeon Vega Series / Radeon Vega Mobile Series]` + │ - `name.split("]", 1)` → `["Advanced Micro Devices, Inc. [AMD/ATI", " Renoir [Radeon Vega Series + │ / Radeon Vega Mobile Series]"]` + │ - `[1]` = ` Renoir [Radeon Vega Series / Radeon Vega Mobile Series]` + │ - `.split("[", 1)` → `[" Renoir ", "Radeon Vega Series / Radeon Vega Mobile Series]"]` + │ - `[0].strip()` = `Renoir` + │ + │ So the result = `AMD/ATI Renoir (Radeon Vega Series)`. + │ + │ That's how it's shortened. Good. + │ + │ Now, in the case of LACT's `lact cli list`, the name inside th + │ … [5667 more chars] + │ + │ Let me look at the overview template and config to complete my understanding: + │ + │ read /home/mac/Code/dashboard/templates/overview.html ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/templates/overview.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/overview.html + │ file + │ + │ 1:
+ │ 2: {% macro bar(pct) %}{% if pct is not none %}
{% + │ endif %}{% endmacro %} + │ 3: {% macro barrow(label, pct, cls) %}{% if pct is not none %}
{{ label }}
{% endif %}{% endmacro %} + │ 4:
+ │ 5:
CPU / GPU
+ │ 6:
{{ (c.cpu | default(0, true)) | round(1) }}%
+ │ 7: {{ barrow("CPU", c.cpu) }} + │ 8: {{ barrow("GPU", c.gpu, "gpu") }} + │ 9: {% if c.cpu_temp is not none or c.gpu_temp is not none %} + │ 10:
+ │ 11: {% if c.cpu_temp is not none %}CPU {{ c.cpu_temp | round(0) | int }}°C{% + │ endif %} + │ 12: {% if c.cpu_temp is not none and c.gpu_temp is not none %} ·{% endif %} + │ 13: {% if c.gpu_temp is not none %}GPU {{ c.gpu_temp | round(0) | int }}°C{% + │ endif %} + │ 14:
+ │ 15: {% endif %} + │ 16:
load {{ (c.load1 | default(0, true)) | round(2) }} · {{ + │ c.cores }} cores
+ │ 17:
+ │ 18:
+ │ 19:
Memory
+ │ 20:
{{ c.mem_used | humanize }} / {{ + │ c.mem_total | humanize }} ({{ (c.mem_pct | default(0, true)) | round(0) | int + │ }}%)
+ │ 21: {{ bar(c.mem_pct) }} + │ 22:
+ │ 23:
Swap {% if c.swap_total %}{{ c.swap_used | humanize }} + │ / {{ c.swap_total | humanize }} ({{ (c.swap_pct | default(0, true)) | + │ … [2464 more chars] + │ read /home/mac/Code/dashboard/app/config.py ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/app/config.py" + │ } + │ out + │ /home/mac/Code/dashboard/app/config.py + │ file + │ + │ 1: from functools import lru_cache + │ 2: + │ 3: from pydantic_settings import BaseSettings, SettingsConfigDict + │ 4: + │ 5: + │ 6: class Settings(BaseSettings): + │ 7: model_config = SettingsConfigDict(env_prefix="DASH_", env_file=".env", + │ extra="ignore") + │ 8: + │ 9: host: str = "127.0.0.1" + │ 10: port: int = 8501 + │ 11: sample_interval: float = 2.0 + │ 12: retention_minutes: int = 60 + │ 13: chart_max_points: int = 200 + │ 14: + │ 15: llama_base_url: str = "http://127.0.0.1:8080" + │ 16: llama_api_key: str = "" + │ 17: llama_timeout: float = 4.0 + │ 18: + │ 19: @property + │ 20: def history_maxlen(self) -> int: + │ 21: return max(10, int(self.retention_minutes * 60 / self.sample_interval)) + │ 22: + │ 23: + │ 24: @lru_cache + │ 25: def get_settings() -> Settings: + │ 26: return Settings() + │ + │ (End of file - total 26 lines) + │ + │ + │ tokens 305 in 2307 out 25223 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:48:51 • assistant • Qwen3.8-27B-Think @plan + │ + │ Hmm, interesting — the overview.html in this repo version doesn't display gpu_name? Wait, in the + │ session log `{{ c.gpu_name }}` was displayed inside the card. But the current overview.html + │ doesn't include gpu_name at all. Let me double-check... Lines 18-30 are the Memory card. There's + │ no GPU card that displays gpu_name. Hmm, wait — but overview.py passes gpu_name. Maybe the + │ gpu_name display was removed, or it's on a different line. Let me grep for gpu_name in the + │ templates. + │ + │ From the earlier grep: `templates/overview.html` didn't come up in the gpu_name matches (only + │ app/routers/overview.py:69 and session logs). So the current overview template doesn't render + │ gpu_name. Hmm. + │ + │ Wait, let me grep again — the earlier grep pattern `gpu_name` matched 53 places, all inside app/ + │ and the opencode session files. Nothing inside templates/. So the overview card currently + │ doesn't display the GPU name? + │ + │ Hmm, but the user says "shorten the GPU name the same way as the overview card." Maybe the GPU + │ name display is elsewhere — maybe there's a "GPU" card? Let me grep "card-label" in + │ overview.html: CPU / GPU, Memory, Battery, Network, Uptime. The CPU/GPU card doesn't display a + │ name. + │ + │ Hmm, wait. Maybe I misread. Let me re-check the git log / git status to see if this is the + │ latest state. Or maybe the user is referring to the `_shorten` function inside + │ app/collect/gpu.py that's used for the overview (even if the template currently doesn't display + │ it — or maybe the user wants it displayed). Let me check git log to understand the state of the + │ repo. + │ + │ bash git log --oneline -10 && git status --short ok 0.0s + │ in + │ { + │ "command": "git log --oneline -10 && git status --short", + │ "workdir": "/home/mac/Code/dashboard" + │ } + │ out + │ 14f44f2 Move opencode traces to subdir + │ e8a3171 Implement sleep inhibitor plugin + │ a3b5bcf Inline service information into the table to make it more accessible + │ bd6a503 Refactor: Store samples in a Sample Dataclass instead of loose dicts + │ f92984b Bugfix: When interacting with a plugin fragment the automatic reload interfered with + │ user actions + │ 23341d8 Bugfix: Cursor for journal tab not applied correctly causing duplicated entries + │ 9fa4e15 Bugfix: Flickering overview charts because of bad choice of point reducing method + │ 6fdfde8 Initial commit + │ grep GPU|gpu ok 0.0s + │ in + │ { + │ "pattern": "GPU|gpu", + │ "path": "/home/mac/Code/dashboard/templates", + │ "include": "*.html" + │ } + │ out + │ Found 9 matches + │ /home/mac/Code/dashboard/templates/index.html: + │ Line 27:

CPU / GPU %

+ │ + │ + │ /home/mac/Code/dashboard/templates/overview.html: + │ Line 5:
CPU / GPU
+ │ + │ Line 8: {{ barrow("GPU", c.gpu, "gpu") }} + │ + │ Line 9: {% if c.cpu_temp is not none or c.gpu_temp is not none %} + │ + │ Line 12: {% if c.cpu_temp is not none and c.gpu_temp is not none %} ·{% + │ endif %} + │ + │ Line 13: {% if c.gpu_temp is not none %}GPU {{ c.gpu_temp | round(0) | int + │ }}°C{% endif %} + │ + │ + │ /home/mac/Code/dashboard/templates/processes.html: + │ Line 18: {% for col, label in [("pid", "PID"), ("name", "Name"), ("user", + │ "User"), ("cpu", "CPU%"), ("mem_pct", "MEM%"), ("rss", "RSS"), ("gpu", "GPU"), ("io_read", + │ "IO read"), ("io_write", "IO write")] %} + │ + │ Line 19: + │ + │ Line 34: {% if p.gpu is not none %}{{ p.gpu }} MB{% else %}{% endif %} + │ + │ tokens 1975 in 486 out 27834 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 21:49:08 • assistant • Qwen3.8-27B-Think @plan + │ + │ The current overview.html doesn't display gpu_name — the "shortening on the overview card" + │ refers to the `_shorten()` function in `app/collect/gpu.py`, which is used for `Sample.gpu_name` + │ (the value that the overview card would display / was intended to display). In fact, looking at + │ the session logs, at some point the overview did display `{{ c.gpu_name }}`. In the current + │ code, the template no longer renders it, but the shortening logic in + │ `app/collect/gpu.py:_shorten` is "the shortening on the overview card." + │ + │ So the plan: the LACT plugin should parse the lines of `lact cli list`, and for each GPU shorten + │ the name in parentheses using the same `_shorten` logic as `app/collect/gpu.py`. + │ + │ Now, the LACT name format is: `(Renoir [Radeon Vega Series / Radeon Vega Mobile Series])` — one + │ bracket group. The current `_shorten` returns `name[:50]` when there is only one group. Applying + │ `_shorten` to `Renoir [Radeon Vega Series / Radeon Vega Mobile Series]` would give back the + │ whole string (49 characters). Hmm. + │ + │ Options: + │ 1. Extend `_shorten` in `app/collect/gpu.py` to also handle the single-group case: `model + │ [series / ...]` → `model (series)`. Then import it into the LACT plugin. This changes the shared + │ function but preserves the "same as the overview card" behavior — both use the same shortening. + │ 2. Reuse `_gpu_name` — no, that's lspci-based and single-GPU. + │ + │ I think extending `_shorten` to handle the one-group case is clean and matches the user's + │ request. Let's verify: applying the extended `_shorten` to the lspci name `Advanced Micro + │ Devices, Inc. [AMD/ATI] Renoir [Radeon Vega Series / Radeon Vega Mobile Series]` → 2 groups → + │ `AMD/ATI Renoir (Radeon Vega Series)`. No change. + │ + │ Applying it to the LACT name `Renoir [Radeon Vega Series / Radeon Vega Mobile Series]` → 1 group + │ → model = `Renoir`, series = `Radeon Vega Series` → `Renoir (Radeon Vega Series)`. + │ + │ This is consistent. I'll propose this in the plan. + │ + │ Next, the plugin design: + │ + │ **Files:** + │ 1. `app/plugins/lact.py` — new p + │ … [14609 more chars] + │ + │ read /home/mac/Code/dashboard/templates/index.html ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/templates/index.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/index.html + │ file + │ + │ 1: + │ 2: + │ 3: + │ 4: + │ 5: + │ 6: {{ hostname }} + │ 7: + │ 8: + │ 9: + │ 10: + │ 11: + │ 12:
+ │ 13:

{{ hostname }}

+ │ 14: + │ 22:
+ │ 23:
+ │ 24:
+ │ 25:
+ │ 26:
+ │ 27:

CPU / GPU %

+ │ 28:

Memory / VRAM %

+ │ 29:

Disk I/O

+ │ 30:
+ │ 31:
+ │ 32: + │ 33: + │ 36: + │ 37: