Bugfix: When interacting with a plugin fragment the automatic reload
interfered with user actions
This commit is contained in:
parent
23341d803c
commit
f92984b113
9 changed files with 2374 additions and 83 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
2283
opencode_session_plugin_skeleton_2026-08-30.txt
Normal file
2283
opencode_session_plugin_skeleton_2026-08-30.txt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
24
templates/plugins/llamacpp_skeleton.html
Normal file
24
templates/plugins/llamacpp_skeleton.html
Normal 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>
|
||||
31
templates/plugins/llamacpp_state.html
Normal file
31
templates/plugins/llamacpp_state.html
Normal 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 %}
|
||||
Loading…
Reference in a new issue