Bugfix: When interacting with a plugin fragment the automatic reload

interfered with user actions
This commit is contained in:
Johannes Schriewer 2026-08-30 19:49:37 +02:00
parent 23341d803c
commit f92984b113
9 changed files with 2374 additions and 83 deletions

View file

@ -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

View file

@ -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()

View file

@ -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,
)

View file

@ -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"<div class='alert'>plugin error: {e}</div>"
items.append({"p": p, "frag": frag})
body = f"<div class='alert'>plugin error: {e}</div>"
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())

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
<div class="plugin-card">
<h3>{{ item.p.title }}</h3>
<p class="muted small">{{ item.p.description }}</p>
<div class="plugin-body" hx-get="/api/plugins/{{ item.p.id }}/fragment" hx-trigger="every {{ item.p.poll_seconds }}s" hx-swap="innerHTML">{{ item.frag | safe }}</div>
<div class="plugin-body">{{ item.body | safe }}</div>
</div>
{% else %}
<div class="muted">no plugins registered</div>

View file

@ -1,51 +0,0 @@
<div class="llama">
{% if not reachable %}
<div class="alert">llama-server unreachable at <span class="mono">{{ base_url }}</span>{% if error %}: {{ error }}{% endif %}</div>
<div class="muted small">start it in router mode: <span class="mono">llama-server --models-dir /path/to/models</span></div>
{% else %}
<div class="llama-health">
<span class="dot {{ "dot-run" if health == "ok" else "dot-failed" }}"></span>
<span class="llama-health-text">{{ "healthy" if health == "ok" else (health or "status unknown") }}</span>
<span class="muted small mono">{{ base_url }}</span>
</div>
{% if message %}<div class="notice">{{ message }}</div>{% endif %}
{% if error_msg %}<div class="alert">{{ error_msg }}</div>{% endif %}
<h4>Loaded models</h4>
{% if loaded %}
<div class="llama-models">
{% for m in loaded %}
<div class="llama-model">
<span class="dot {{ "dot-run" if m.state == "loaded" else ("dot-sleep" if m.state == "sleeping" else ("dot-failed" if m.failed else "dot-busy")) }}"></span>
<span class="mono llama-id" title="{{ m.path }}">{{ m.id }}</span>
<span class="badge badge-{{ "failed" if m.failed else m.state }}">{{ m.state }}{% if m.failed %} (exit {{ m.exit_code }}){% endif %}</span>
{% if m.progress is not none %}<div class="bar small-bar"><div class="bar-fill" style="width: {{ m.progress }}%"></div></div>{% endif %}
<span class="llama-model-actions">
<button class="btn" hx-post="/api/plugins/llamacpp/unload" hx-vals='{"model":"{{ m.id }}"}' hx-target="closest .plugin-body" hx-swap="innerHTML" hx-confirm="Unload {{ m.id }}?">unload</button>
</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="muted small">no models loaded</div>
{% endif %}
<h4>Available models</h4>
{% if available %}
<div class="llama-load">
<select id="llama-model-pick" name="model">
{% for m in available %}
<option value="{{ m.id }}">{{ m.id }}{% if m.failed %} (failed, exit {{ m.exit_code }}){% endif %}</option>
{% endfor %}
</select>
<button class="btn" hx-post="/api/plugins/llamacpp/load" hx-include="#llama-model-pick" hx-target="closest .plugin-body" hx-swap="innerHTML">load</button>
</div>
{% else %}
<div class="muted small">no unloaded models (is --models-dir set on the server?)</div>
{% endif %}
<div class="actions">
<button class="btn" hx-post="/api/plugins/llamacpp/rescan" hx-target="closest .plugin-body" hx-swap="innerHTML">rescan models</button>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,24 @@
<div class="llama">
<div id="llama-state" hx-get="/api/plugins/llamacpp/state" hx-trigger="every 5s" hx-swap="innerHTML">
{% include "plugins/llamacpp_state.html" %}
</div>
{% if reachable %}
<h4>Available models</h4>
{% if available %}
<div class="llama-load">
<select id="llama-model-pick" name="model">
{% for m in available %}
<option value="{{ m.id }}">{{ m.id }}{% if m.failed %} (failed, exit {{ m.exit_code }}){% endif %}</option>
{% endfor %}
</select>
<button class="btn" hx-post="/api/plugins/llamacpp/load" hx-include="#llama-model-pick" hx-target="closest .plugin-body" hx-swap="innerHTML">load</button>
</div>
{% else %}
<div class="muted small">no unloaded models (is --models-dir set on the server?)</div>
{% endif %}
<div class="actions">
<button class="btn" hx-post="/api/plugins/llamacpp/rescan" hx-target="closest .plugin-body" hx-swap="innerHTML">rescan models</button>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,31 @@
{% if not reachable %}
<div class="alert">llama-server unreachable at <span class="mono">{{ base_url }}</span>{% if error %}: {{ error }}{% endif %}</div>
<div class="muted small">start it in router mode: <span class="mono">llama-server --models-dir /path/to/models</span></div>
{% else %}
<div class="llama-health">
<span class="dot {{ "dot-run" if health == "ok" else "dot-failed" }}"></span>
<span class="llama-health-text">{{ "healthy" if health == "ok" else (health or "status unknown") }}</span>
<span class="muted small mono">{{ base_url }}</span>
</div>
{% if message %}<div class="notice">{{ message }}</div>{% endif %}
{% if error_msg %}<div class="alert">{{ error_msg }}</div>{% endif %}
<h4>Loaded models</h4>
{% if loaded %}
<div class="llama-models">
{% for m in loaded %}
<div class="llama-model">
<span class="dot {{ "dot-run" if m.state == "loaded" else ("dot-sleep" if m.state == "sleeping" else ("dot-failed" if m.failed else "dot-busy")) }}"></span>
<span class="mono llama-id" title="{{ m.path }}">{{ m.id }}</span>
<span class="badge badge-{{ "failed" if m.failed else m.state }}">{{ m.state }}{% if m.failed %} (exit {{ m.exit_code }}){% endif %}</span>
{% if m.progress is not none %}<div class="bar small-bar"><div class="bar-fill" style="width: {{ m.progress }}%"></div></div>{% endif %}
<span class="llama-model-actions">
<button class="btn" hx-post="/api/plugins/llamacpp/unload" hx-vals='{"model":"{{ m.id }}"}' hx-target="closest .plugin-body" hx-swap="innerHTML" hx-confirm="Unload {{ m.id }}?">unload</button>
</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="muted small">no models loaded</div>
{% endif %}
{% endif %}