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`
|
Adding a plugin: create a module in `app/plugins/` defining a `Plugin`
|
||||||
instance (id, title, poll interval, fragment function) and register it in
|
instance (id, title, skeleton function) and register it in
|
||||||
`app/plugins/__init__.py`.
|
`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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,9 @@ class Plugin:
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
poll_seconds: int = 5
|
skeleton_fn: Callable[[], Awaitable[str]] | None = field(default=None)
|
||||||
fragment_fn: Callable[[], Awaitable[str]] | None = field(default=None)
|
|
||||||
|
|
||||||
async def fragment(self) -> str:
|
async def skeleton(self) -> str:
|
||||||
if self.fragment_fn is None:
|
if self.skeleton_fn is None:
|
||||||
raise NotImplementedError
|
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__}"
|
return False, f"unreachable: {e.__class__.__name__}"
|
||||||
|
|
||||||
|
|
||||||
async def _fragment(message: str = "", error: str = "") -> str:
|
def _with_lists(status: dict[str, Any]) -> dict[str, Any]:
|
||||||
status = await gather_status()
|
|
||||||
status["message"] = message
|
|
||||||
status["error_msg"] = error
|
|
||||||
active = {m["id"] for m in status["models"] if m["state"] in ("loaded", "sleeping", "loading")}
|
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["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["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["loaded"].sort(key=lambda m: (m["state"] != "loaded", m["state"] != "sleeping", m["id"]))
|
||||||
status["available"].sort(key=lambda m: m["id"])
|
status["available"].sort(key=lambda m: m["id"])
|
||||||
return render("plugins/llamacpp.html", **status)
|
return status
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fragment")
|
async def _status(message: str, error: str) -> dict[str, Any]:
|
||||||
async def fragment():
|
status = _with_lists(await gather_status())
|
||||||
return HTMLResponse(await _fragment())
|
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")
|
@router.post("/load")
|
||||||
async def load(model: str = Form(...)):
|
async def load(model: str = Form(...)):
|
||||||
ok, err = await _action("/models/load", model)
|
ok, err = await _action("/models/load", model)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
await _fragment(
|
await _skeleton(
|
||||||
message=f"loading {model}" if ok else "",
|
message=f"loading {model}" if ok else "",
|
||||||
error="" if ok else err,
|
error="" if ok else err,
|
||||||
)
|
)
|
||||||
|
|
@ -119,7 +131,7 @@ async def load(model: str = Form(...)):
|
||||||
async def unload(model: str = Form(...)):
|
async def unload(model: str = Form(...)):
|
||||||
ok, err = await _action("/models/unload", model)
|
ok, err = await _action("/models/unload", model)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
await _fragment(
|
await _skeleton(
|
||||||
message=f"unloading {model}" if ok else "",
|
message=f"unloading {model}" if ok else "",
|
||||||
error="" if ok else err,
|
error="" if ok else err,
|
||||||
)
|
)
|
||||||
|
|
@ -138,13 +150,12 @@ async def rescan():
|
||||||
msg, err = "", f"http {r.status_code}"
|
msg, err = "", f"http {r.status_code}"
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
msg, err = "", f"unreachable: {e.__class__.__name__}"
|
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(
|
plugin = Plugin(
|
||||||
id="llamacpp",
|
id="llamacpp",
|
||||||
title="llama.cpp",
|
title="llama.cpp",
|
||||||
description="Loaded model status for a llama-server in router mode, with load / unload controls.",
|
description="Loaded model status for a llama-server in router mode, with load / unload controls.",
|
||||||
poll_seconds=5,
|
skeleton_fn=_skeleton,
|
||||||
fragment_fn=_fragment,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,8 @@ async def plugins_index():
|
||||||
items: list[dict[str, Plugin | str]] = []
|
items: list[dict[str, Plugin | str]] = []
|
||||||
for p in PLUGINS:
|
for p in PLUGINS:
|
||||||
try:
|
try:
|
||||||
frag = await p.fragment()
|
body = await p.skeleton()
|
||||||
except Exception as e: # noqa
|
except Exception as e: # noqa
|
||||||
frag = f"<div class='alert'>plugin error: {e}</div>"
|
body = f"<div class='alert'>plugin error: {e}</div>"
|
||||||
items.append({"p": p, "frag": frag})
|
items.append({"p": p, "body": body})
|
||||||
return HTMLResponse(render("plugins.html", items=items))
|
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">
|
<div class="plugin-card">
|
||||||
<h3>{{ item.p.title }}</h3>
|
<h3>{{ item.p.title }}</h3>
|
||||||
<p class="muted small">{{ item.p.description }}</p>
|
<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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="muted">no plugins registered</div>
|
<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