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