Implement sleep inhibitor plugin

This commit is contained in:
Johannes Schriewer 2026-08-30 21:34:09 +02:00
parent a3b5bcf2c5
commit e8a3171983
11 changed files with 268 additions and 9 deletions

View file

@ -52,8 +52,11 @@ agent's own shell command line and kills the session.
template-only edits. Python changes require a restart. template-only edits. Python changes require a restart.
- `app/systemd/units.py` — systemd unit listing/detail/actions; - `app/systemd/units.py` — systemd unit listing/detail/actions;
`app/journal.py``journalctl -o export` parser with cursors. `app/journal.py``journalctl -o export` parser with cursors.
- `app/plugins/``base.Plugin` + llamacpp plugin (talks to a router-mode - `app/plugins/``base.Plugin` (optional `open`/`close` lifecycle hooks run
`llama-server` on port 8080). from app lifespan) + llamacpp plugin (talks to a router-mode `llama-server`
on port 8080) + sleep plugin (lists block-mode `systemd-inhibit` locks;
holds its own sleep lock via a `systemd-inhibit ... sleep infinity` child
while the UI switch is on, reaps stale locks by `who` marker on startup).
## Conventions ## Conventions

View file

@ -81,7 +81,10 @@ in the project root is read automatically (see `.env.example`).
name for details (main PID, start time, restarts, recent journal lines) and name for details (main PID, start time, restarts, recent journal lines) and
run `start` / `stop` / `restart` / `enable` / `disable` actions. run `start` / `stop` / `restart` / `enable` / `disable` actions.
- **Plugins** — currently **llama.cpp**: model status, load/unload buttons and - **Plugins** — currently **llama.cpp**: model status, load/unload buttons and
a rescan for a `llama-server` running in router mode. a rescan for a `llama-server` running in router mode; and **sleep
inhibitors**: active block-mode `systemd-inhibit` locks with a verdict on
whether the machine may sleep right now, plus a switch that makes the
dashboard itself hold a sleep lock (released again on shutdown).
### llama.cpp router mode ### llama.cpp router mode
@ -127,7 +130,7 @@ app/
journal.py # journalctl -o export parser + cursors journal.py # journalctl -o export parser + cursors
render.py # jinja env + filters render.py # jinja env + filters
routers/ # overview / disks / processes / journal / services / plugins routers/ # overview / disks / processes / journal / services / plugins
plugins/ # base.Plugin + llamacpp plugin plugins/ # base.Plugin + llamacpp + sleep plugins
templates/ # htmx fragments templates/ # htmx fragments
static/ # css, js, vendored htmx + chart.js static/ # css, js, vendored htmx + chart.js
deploy/ # dashboard.service deploy/ # dashboard.service

View file

@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.config import get_settings from app.config import get_settings
from app.plugins import ROUTERS as PLUGIN_ROUTERS from app.plugins import PLUGINS, ROUTERS as PLUGIN_ROUTERS
from app.render import BASE, render from app.render import BASE, render
from app.routers import disks, overview, plugins, processes, services from app.routers import disks, overview, plugins, processes, services
from app.routers import journal as journal_router from app.routers import journal as journal_router
@ -20,6 +20,11 @@ async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
app.state.settings = settings app.state.settings = settings
app.state.store = HistoryStore(maxlen=settings.history_maxlen) app.state.store = HistoryStore(maxlen=settings.history_maxlen)
for p in PLUGINS:
try:
await p.open()
except Exception: # noqa
pass
task = asyncio.create_task(sampler_loop(app.state.store, settings.sample_interval)) task = asyncio.create_task(sampler_loop(app.state.store, settings.sample_interval))
yield yield
_ = task.cancel() _ = task.cancel()
@ -27,6 +32,11 @@ async def lifespan(app: FastAPI):
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
for p in PLUGINS:
try:
await p.close()
except Exception: # noqa
pass
def create_app() -> FastAPI: def create_app() -> FastAPI:

View file

@ -1,7 +1,8 @@
from app.plugins.base import Plugin from app.plugins.base import Plugin
from app.plugins.llamacpp import plugin as llamacpp_plugin, router as llamacpp_router from app.plugins.llamacpp import plugin as llamacpp_plugin, router as llamacpp_router
from app.plugins.sleep import plugin as sleep_plugin, router as sleep_router
PLUGINS: list[Plugin] = [llamacpp_plugin] PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin]
ROUTERS = [llamacpp_router] ROUTERS = [llamacpp_router, sleep_router]
__all__ = ["PLUGINS", "ROUTERS", "Plugin"] __all__ = ["PLUGINS", "ROUTERS", "Plugin"]

View file

@ -8,8 +8,18 @@ class Plugin:
title: str title: str
description: str = "" description: str = ""
skeleton_fn: Callable[[], Awaitable[str]] | None = field(default=None) skeleton_fn: Callable[[], Awaitable[str]] | None = field(default=None)
open_fn: Callable[[], Awaitable[None]] | None = field(default=None)
close_fn: Callable[[], Awaitable[None]] | None = field(default=None)
async def skeleton(self) -> str: async def skeleton(self) -> str:
if self.skeleton_fn is None: if self.skeleton_fn is None:
raise NotImplementedError raise NotImplementedError
return await self.skeleton_fn() return await self.skeleton_fn()
async def open(self) -> None:
if self.open_fn is not None:
await self.open_fn()
async def close(self) -> None:
if self.close_fn is not None:
await self.close_fn()

View file

@ -156,6 +156,6 @@ async def rescan():
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.",
skeleton_fn=_skeleton, skeleton_fn=_skeleton,
) )

196
app/plugins/sleep.py Normal file
View file

@ -0,0 +1,196 @@
import asyncio
import json
import os
import signal
from typing import Any
from fastapi import APIRouter, Form
from fastapi.responses import HTMLResponse
from app.plugins.base import Plugin
from app.render import render
router = APIRouter(prefix="/api/plugins/sleep", tags=["plugins"])
WHO = "Dashboard (sleep-inhibit)"
WHY = "dashboard: keep system awake"
BLOCK_MODES = ("block", "block-weak")
_holder: asyncio.subprocess.Process | None = None
_toggle_lock = asyncio.Lock()
async def _list() -> tuple[list[dict[str, Any]], str]:
try:
proc = await asyncio.create_subprocess_exec(
"systemd-inhibit", "--json=short", "--list",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as e:
return [], str(e)[:200]
try:
out, err = await asyncio.wait_for(proc.communicate(), 5)
except TimeoutError:
try:
_ = proc.kill()
except ProcessLookupError:
pass
return [], "systemd-inhibit timed out"
if proc.returncode != 0:
return [], (err.decode(errors="replace").strip() or f"systemd-inhibit failed (rc={proc.returncode})")[:200]
try:
data = json.loads(out.decode(errors="replace"))
except ValueError:
return [], "could not parse systemd-inhibit output"
if not isinstance(data, list):
return [], "unexpected systemd-inhibit output"
items: list[dict[str, Any]] = [e for e in data if isinstance(e, dict)]
return items, ""
def _verdict(inhibitors: list[dict[str, Any]]) -> str:
for e in inhibitors:
whats = str(e.get("what", "")).split(":")
if "sleep" in whats and e.get("mode") in BLOCK_MODES:
return "blocked"
return "ok"
def _rows(inhibitors: list[dict[str, Any]]) -> list[dict[str, str | bool]]:
rows: list[dict[str, str | bool]] = []
for e in inhibitors:
mode = str(e.get("mode", ""))
if mode not in BLOCK_MODES:
continue
user = str(e.get("user", ""))
pid = e.get("pid")
if isinstance(pid, int) and pid > 0:
proc = f"{user} · {pid}" if user else str(pid)
else:
proc = user
rows.append({
"who": str(e.get("who", "?")),
"proc": proc,
"what": str(e.get("what", "")),
"why": str(e.get("why", "")),
"mode": mode,
"own": e.get("who") == WHO,
})
return rows
def _reap_dead_holder() -> None:
global _holder
if _holder is not None and _holder.returncode is not None:
_holder = None
def _context(inhibitors: list[dict[str, Any]], error: str, message: str = "") -> dict[str, Any]:
_reap_dead_holder()
return {
"inhibitors": _rows(inhibitors),
"verdict": _verdict(inhibitors),
"message": message,
"error": error,
"holding": _holder is not None,
}
async def _state(message: str = "", error: str = "") -> str:
inhibitors, err = await _list()
if error:
err = error
return render("plugins/sleep_state.html", **_context(inhibitors, err, message))
async def _skeleton(message: str = "", error: str = "") -> str:
inhibitors, err = await _list()
if error:
err = error
return render("plugins/sleep_skeleton.html", **_context(inhibitors, err, message))
async def _acquire() -> str:
global _holder
try:
_holder = await asyncio.create_subprocess_exec(
"systemd-inhibit",
"--what=sleep",
"--mode=block",
f"--who={WHO}",
f"--why={WHY}",
"sleep", "infinity",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
start_new_session=True,
)
except OSError as e:
return str(e)[:200]
return ""
async def _release() -> None:
global _holder
p, _holder = _holder, None
if p is None:
return
try:
os.killpg(p.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
try:
_ = await asyncio.wait_for(p.wait(), 3)
except TimeoutError:
try:
os.killpg(p.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
_ = await p.wait()
@router.get("/state")
async def state():
return HTMLResponse(await _state())
@router.post("/toggle")
async def toggle(on: str | None = Form(None)):
async with _toggle_lock:
if on and _holder is None:
err = await _acquire()
if err:
return HTMLResponse(await _skeleton(error=err))
return HTMLResponse(await _skeleton(message="inhibiting sleep"))
if not on and _holder is not None:
await _release()
return HTMLResponse(await _skeleton(message="sleep inhibition released"))
return HTMLResponse(await _skeleton())
async def _open() -> None:
inhibitors, _err = await _list()
for e in inhibitors:
if e.get("who") != WHO:
continue
pid = e.get("pid")
if not isinstance(pid, int) or pid <= 0:
continue
try:
_ = os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
async def _close() -> None:
await _release()
plugin = Plugin(
id="sleep",
title="Sleep inhibitors",
description="Active block-mode systemd inhibitor locks.",
skeleton_fn=_skeleton,
open_fn=_open,
close_fn=_close,
)

View file

@ -239,6 +239,7 @@ button:hover { border-color: var(--accent); }
.badge-unloaded { color: var(--muted); } .badge-unloaded { color: var(--muted); }
.badge-sleeping { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); } .badge-sleeping { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); }
.badge-failed { color: #ffcdd2; border-color: rgba(239, 83, 80, .6); background: rgba(239, 83, 80, .12); } .badge-failed { color: #ffcdd2; border-color: rgba(239, 83, 80, .6); background: rgba(239, 83, 80, .12); }
.badge-block { color: #ffcdd2; border-color: rgba(239, 83, 80, .6); background: rgba(239, 83, 80, .12); }
.badge-downloading { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); } .badge-downloading { color: #b3e5fc; border-color: rgba(79, 195, 247, .5); background: rgba(79, 195, 247, .1); }
.llama-id { overflow: hidden; text-overflow: ellipsis; } .llama-id { overflow: hidden; text-overflow: ellipsis; }
.llama-health { .llama-health {
@ -270,3 +271,4 @@ button:hover { border-color: var(--accent); }
.llama-load select { max-width: 420px; } .llama-load select { max-width: 420px; }
.part-mounts { max-width: 420px; overflow: hidden; text-overflow: ellipsis; } .part-mounts { max-width: 420px; overflow: hidden; text-overflow: ellipsis; }
.part-usage { margin-left: auto; } .part-usage { margin-left: auto; }
.inh-own { background: rgba(79, 195, 247, .08); }

View file

@ -1,5 +1,4 @@
<div id="plugins" class="panel"> <div id="plugins" class="panel">
<div class="muted small">plugins load from <span class="mono">app/plugins/</span> &mdash; each module exposes a <span class="mono">Plugin</span> instance</div>
{% for item in items %} {% for item in items %}
<div class="plugin-card"> <div class="plugin-card">
<h3>{{ item.p.title }}</h3> <h3>{{ item.p.title }}</h3>

View file

@ -0,0 +1,5 @@
<div class="sleep">
<div id="sleep-state" hx-get="/api/plugins/sleep/state" hx-trigger="every 5s" hx-swap="innerHTML">
{% include "plugins/sleep_state.html" %}
</div>
</div>

View file

@ -0,0 +1,30 @@
{% if error %}<div class="alert">systemd-inhibit failed: {{ error }}</div>{% endif %}
{% if message %}<div class="notice">{{ message }}</div>{% endif %}
<div class="llama-health">
<span class="dot {{ "dot-failed" if verdict == "blocked" else "dot-run" }}"></span>
<span class="llama-health-text">{{ "sleep blocked" if verdict == "blocked" else "sleep allowed" }}</span>
<span class="muted small">block-mode locks only — delay-mode locks are ignored</span>
</div>
<label class="chk">
<input type="checkbox" name="on" value="1" {% if holding %}checked{% endif %} hx-post="/api/plugins/sleep/toggle" hx-target="closest .plugin-body" hx-swap="innerHTML">
keep system awake (this dashboard holds a sleep lock)
</label>
{% if inhibitors %}
<div class="table-wrap">
<table class="table">
<thead><tr><th>who</th><th>what</th><th>why</th><th>mode</th></tr></thead>
<tbody>
{% for e in inhibitors %}
<tr class="{{ "inh-own" if e.own else "" }}">
<td class="cell-clip">{{ e.who }}{% if e.proc %} <span class="muted small mono">{{ e.proc }}</span>{% endif %}</td>
<td>{% for w in e.what.split(":") if w %}<span class="badge">{{ w }}</span> {% endfor %}</td>
<td class="cell-clip muted">{{ e.why }}</td>
<td><span class="badge {{ "badge-block" if e.mode == "block" else "" }}">{{ e.mode }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="muted small">no active block-mode inhibitors — the system may sleep</div>
{% endif %}