diff --git a/README.md b/README.md index cf1eb53..afc7b63 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,10 @@ deploy/ # dashboard.service ``` Adding a plugin: create a module in `app/plugins/` defining a `Plugin` -instance (id, title, poll interval, fragment function) and register it in -`app/plugins/__init__.py`. +instance (id, title, skeleton function) and register it in +`app/plugins/__init__.py`. The skeleton is the static card shell, rendered +once; it embeds the dynamically polled regions (e.g. a div with +`hx-get`/`hx-trigger="every Ns"` pointing at the plugin's own endpoints). ## License diff --git a/app/plugins/base.py b/app/plugins/base.py index b3e2a03..1506343 100644 --- a/app/plugins/base.py +++ b/app/plugins/base.py @@ -7,10 +7,9 @@ class Plugin: id: str title: str description: str = "" - poll_seconds: int = 5 - fragment_fn: Callable[[], Awaitable[str]] | None = field(default=None) + skeleton_fn: Callable[[], Awaitable[str]] | None = field(default=None) - async def fragment(self) -> str: - if self.fragment_fn is None: + async def skeleton(self) -> str: + if self.skeleton_fn is None: raise NotImplementedError - return await self.fragment_fn() + return await self.skeleton_fn() diff --git a/app/plugins/llamacpp.py b/app/plugins/llamacpp.py index f8e12fe..6d955c8 100644 --- a/app/plugins/llamacpp.py +++ b/app/plugins/llamacpp.py @@ -87,28 +87,40 @@ async def _action(endpoint: str, model: str) -> tuple[bool, str]: return False, f"unreachable: {e.__class__.__name__}" -async def _fragment(message: str = "", error: str = "") -> str: - status = await gather_status() - status["message"] = message - status["error_msg"] = error +def _with_lists(status: dict[str, Any]) -> dict[str, Any]: active = {m["id"] for m in status["models"] if m["state"] in ("loaded", "sleeping", "loading")} status["loaded"] = [m for m in status["models"] if m["id"] in active] status["available"] = [m for m in status["models"] if m["id"] not in active] status["loaded"].sort(key=lambda m: (m["state"] != "loaded", m["state"] != "sleeping", m["id"])) status["available"].sort(key=lambda m: m["id"]) - return render("plugins/llamacpp.html", **status) + return status -@router.get("/fragment") -async def fragment(): - return HTMLResponse(await _fragment()) +async def _status(message: str, error: str) -> dict[str, Any]: + status = _with_lists(await gather_status()) + status["message"] = message + status["error_msg"] = error + return status + + +async def _state(message: str = "", error: str = "") -> str: + return render("plugins/llamacpp_state.html", **await _status(message, error)) + + +async def _skeleton(message: str = "", error: str = "") -> str: + return render("plugins/llamacpp_skeleton.html", **await _status(message, error)) + + +@router.get("/state") +async def state(): + return HTMLResponse(await _state()) @router.post("/load") async def load(model: str = Form(...)): ok, err = await _action("/models/load", model) return HTMLResponse( - await _fragment( + await _skeleton( message=f"loading {model}" if ok else "", error="" if ok else err, ) @@ -119,7 +131,7 @@ async def load(model: str = Form(...)): async def unload(model: str = Form(...)): ok, err = await _action("/models/unload", model) return HTMLResponse( - await _fragment( + await _skeleton( message=f"unloading {model}" if ok else "", error="" if ok else err, ) @@ -138,13 +150,12 @@ async def rescan(): msg, err = "", f"http {r.status_code}" except httpx.HTTPError as e: msg, err = "", f"unreachable: {e.__class__.__name__}" - return HTMLResponse(await _fragment(message=msg, error=err)) + return HTMLResponse(await _skeleton(message=msg, error=err)) plugin = Plugin( id="llamacpp", title="llama.cpp", description="Loaded model status for a llama-server in router mode, with load / unload controls.", - poll_seconds=5, - fragment_fn=_fragment, + skeleton_fn=_skeleton, ) diff --git a/app/routers/plugins.py b/app/routers/plugins.py index 860eed7..e9ac9d8 100644 --- a/app/routers/plugins.py +++ b/app/routers/plugins.py @@ -12,16 +12,8 @@ async def plugins_index(): items: list[dict[str, Plugin | str]] = [] for p in PLUGINS: try: - frag = await p.fragment() + body = await p.skeleton() except Exception as e: # noqa - frag = f"
plugin error: {e}
" - items.append({"p": p, "frag": frag}) + body = f"
plugin error: {e}
" + items.append({"p": p, "body": body}) return HTMLResponse(render("plugins.html", items=items)) - - -@router.get("/{pid}/fragment") -async def plugin_fragment(pid: str): - plugin = next((p for p in PLUGINS if p.id == pid), None) - if plugin is None: - return HTMLResponse("unknown plugin", status_code=404) - return HTMLResponse(await plugin.fragment()) diff --git a/opencode_session_plugin_skeleton_2026-08-30.txt b/opencode_session_plugin_skeleton_2026-08-30.txt new file mode 100644 index 0000000..1acd5d9 --- /dev/null +++ b/opencode_session_plugin_skeleton_2026-08-30.txt @@ -0,0 +1,2283 @@ +mac 2026-08-29 19:48:20 → now 1 session(s) + +Session Started Where Title +JY7woN 2026-08-30 19:23 Code/dashboard Fragment partial refresh split + + + ⚑ ❬JY7woN❭ Fragment partial refresh split ~/Code/dashboard +──────────────────────────────────────────────────────────────────────────────────────────────────── + + │ 2026-08-30 19:23:28 • user + │ + │ The plugins' fragments reload completely on an automated schedule, we need to split that into a + │ static part and a dynamic part that refreshes automatically. With the automated refresh of the + │ whole fragment it is really hard to select something in a dropdown; it is possible the fragment + │ reloads while the user is choosing an option. + + + │ 2026-08-30 19:23:28 • assistant • Qwen3.8-27B-Think @plan + │ + │ Let's start by understanding the codebase. The user is saying that the plugin fragment is being + │ completely reloaded on a schedule. I need to find where the plugin's template and router are, + │ understand how the htmx polling works, and then propose a plan to split it into static and + │ dynamic parts. + │ + │ Let's read the relevant files: app/plugins/, app/routers/plugins.py (or similar file), + │ templates/plugins*.html, and the llamacpp plugin to understand what parts are static and what + │ parts are dynamic. + │ + │ Let's explore. + │ + │ glob app/plugins/** ok 0.0s + │ in + │ { + │ "pattern": "app/plugins/**" + │ } + │ out + │ /home/mac/Code/dashboard/app/plugins/base.py + │ /home/mac/Code/dashboard/app/plugins/__init__.py + │ /home/mac/Code/dashboard/app/plugins/__pycache__/llamacpp.cpython-314.pyc + │ /home/mac/Code/dashboard/app/plugins/llamacpp.py + │ /home/mac/Code/dashboard/app/plugins/__pycache__/__init__.cpython-314.pyc + │ /home/mac/Code/dashboard/app/plugins/__pycache__/base.cpython-314.pyc + │ glob app/routers/* ok 0.0s + │ in + │ { + │ "pattern": "app/routers/*" + │ } + │ out + │ /home/mac/Code/dashboard/app/routers/disks.py + │ /home/mac/Code/dashboard/app/routers/overview.py + │ /home/mac/Code/dashboard/app/routers/services.py + │ /home/mac/Code/dashboard/app/routers/plugins.py + │ /home/mac/Code/dashboard/app/routers/journal.py + │ /home/mac/Code/dashboard/app/routers/__init__.py + │ /home/mac/Code/dashboard/app/routers/processes.py + │ glob templates/* ok 0.0s + │ in + │ { + │ "pattern": "templates/*" + │ } + │ out + │ /home/mac/Code/dashboard/templates/index.html + │ /home/mac/Code/dashboard/templates/overview.html + │ /home/mac/Code/dashboard/templates/journal.html + │ /home/mac/Code/dashboard/templates/processes.html + │ /home/mac/Code/dashboard/templates/disks.html + │ /home/mac/Code/dashboard/templates/service_detail.html + │ /home/mac/Code/dashboard/templates/services.html + │ /home/mac/Code/dashboard/templates/plugins.html + │ + │ tokens 545 in 198 out 8713 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 19:23:37 • assistant • Qwen3.8-27B-Think @plan + │ + │ Let's try reading the plugin router, base plugin, llamacpp plugin, and the template. + │ + │ 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: frag = await p.fragment() + │ 16: except Exception as e: # noqa + │ 17: frag = f"
plugin error: {e}
" + │ 18: items.append({"p": p, "frag": frag}) + │ 19: return HTMLResponse(render("plugins.html", items=items)) + │ 20: + │ 21: + │ 22: @router.get("/{pid}/fragment") + │ 23: async def plugin_fragment(pid: str): + │ 24: plugin = next((p for p in PLUGINS if p.id == pid), None) + │ 25: if plugin is None: + │ 26: return HTMLResponse("unknown plugin", status_code=404) + │ 27: return HTMLResponse(await plugin.fragment()) + │ + │ (End of file - total 27 lines) + │
+ │ 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: poll_seconds: int = 5 + │ 11: fragment_fn: Callable[[], Awaitable[str]] | None = field(default=None) + │ 12: + │ 13: async def fragment(self) -> str: + │ 14: if self.fragment_fn is None: + │ 15: raise NotImplementedError + │ 16: return await self.fragment_fn() + │ + │ (End of file - total 16 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 + │ … [3871 more chars] + │ 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:
plugins load from app/plugins/ + │ — each module exposes a Plugin instance
+ │ 3: {% for item in items %} + │ 4:
+ │ 5:

{{ item.p.title }}

+ │ 6:

{{ item.p.description }}

+ │ 7:
{{ item.frag | safe + │ }}
+ │ 8:
+ │ 9: {% else %} + │ 10:
no plugins registered
+ │ 11: {% endfor %} + │ 12:
+ │ + │ (End of file - total 12 lines) + │
+ │ + │ tokens 284 in 158 out 9456 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 19:23:41 • assistant • Qwen3.8-27B-Think @plan + │ + │ Alright, let's take a look at the llamacpp.html template to see what's in there — the static + │ part (buttons, labels, dropdown container) and the dynamic part (the dropdown options + │ themselves, the model list). + │ + │ glob templates/plugins/* ok 0.0s + │ in + │ { + │ "pattern": "templates/plugins/*" + │ } + │ out /home/mac/Code/dashboard/templates/plugins/llamacpp.html + │ + │ tokens 2711 in 77 out 9899 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 19:23:46 • assistant • Qwen3.8-27B-Think @plan + │ + │ Let's read the llamacpp.html template and understand the structure. + │ + │ read /home/mac/Code/dashboard/templates/plugins/llamacpp.html ok 0.0s + │ in + │ { + │ "filePath": "/home/mac/Code/dashboard/templates/plugins/llamacpp.html" + │ } + │ out + │ /home/mac/Code/dashboard/templates/plugins/llamacpp.html + │ file + │ + │ 1:
+ │ 2: {% if not reachable %} + │ 3:
llama-server unreachable at {{ base_url + │ }}{% if error %}: {{ error }}{% endif %}
+ │ 4:
start it in router mode: llama-server + │ --models-dir /path/to/models
+ │ 5: {% else %} + │ 6:
+ │ 7: + │ 8: {{ "healthy" if health == "ok" else (health or + │ "status unknown") }} + │ 9: {{ base_url }} + │ 10:
+ │ 11: {% if message %}
{{ message }}
{% endif %} + │ 12: {% if error_msg %}
{{ error_msg }}
{% endif %} + │ 13: + │ 14:

Loaded models

+ │ 15: {% if loaded %} + │ 16:
+ │ 17: {% for m in loaded %} + │ 18:
+ │ 19: + │ 20: {{ m.id }} + │ 21: {{ m.state }}{% + │ if m.failed %} (exit {{ m.exit_code }}){% endif %} + │ 22: {% if m.progress is not none %}
{% endif %} + │ 23: + │ 24: + │ 25: + │ 26:
+ │ 27: {% endfor %} + │ 28:
+ │ 29: {% else %} + │ 30:
no models loaded
+ │ 31: {% endif %} + │ 32: + │ 33:

A + │ … [887 more chars] + │ 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: + │ 4: PLUGINS: list[Plugin] = [llamacpp_plugin] + │ 5: ROUTERS = [llamacpp_router] + │ 6: + │ 7: __all__ = ["PLUGINS", "ROUTERS", "Plugin"] + │ + │ (End of file - total 7 lines) + │ + │ 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] + │ + │ tokens 27 in 120 out 12687 cached 0 written $0.0000 tool-calls + + + │ 2026-08-30 19:23:49 • assistant • Qwen3.8-27B-Think @plan + │ + │ Now I understand the structure. Let me also check how the main page loads the plugins tab, and + │ look at index.html to understand the tab structure and how other tabs handle static/dynamic + │ separation (e.g., services might have a static list + dynamic details). Let me read index.html + │ and see how the plugins panel is initially embedded (it's probably loaded via hx-get to + │ /api/plugins on tab switch). + │ + │ Also, let me check how the other templates handle static/dynamic splits, for example + │ service_detail.html. And check the hx-* attributes in index.html. + │ + │ 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: