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"