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.
- `app/systemd/units.py` — systemd unit listing/detail/actions;
`app/journal.py``journalctl -o export` parser with cursors.
- `app/plugins/``base.Plugin` + llamacpp plugin (talks to a router-mode
`llama-server` on port 8080).
- `app/plugins/``base.Plugin` (optional `open`/`close` lifecycle hooks run
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

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
run `start` / `stop` / `restart` / `enable` / `disable` actions.
- **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
@ -127,7 +130,7 @@ app/
journal.py # journalctl -o export parser + cursors
render.py # jinja env + filters
routers/ # overview / disks / processes / journal / services / plugins
plugins/ # base.Plugin + llamacpp plugin
plugins/ # base.Plugin + llamacpp + sleep plugins
templates/ # htmx fragments
static/ # css, js, vendored htmx + chart.js
deploy/ # dashboard.service

View file

@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
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.routers import disks, overview, plugins, processes, services
from app.routers import journal as journal_router
@ -20,6 +20,11 @@ async def lifespan(app: FastAPI):
settings = get_settings()
app.state.settings = settings
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))
yield
_ = task.cancel()
@ -27,6 +32,11 @@ async def lifespan(app: FastAPI):
await task
except asyncio.CancelledError:
pass
for p in PLUGINS:
try:
await p.close()
except Exception: # noqa
pass
def create_app() -> FastAPI:

View file

@ -1,7 +1,8 @@
from app.plugins.base import Plugin
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]
ROUTERS = [llamacpp_router]
PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin]
ROUTERS = [llamacpp_router, sleep_router]
__all__ = ["PLUGINS", "ROUTERS", "Plugin"]

View file

@ -8,8 +8,18 @@ class Plugin:
title: str
description: str = ""
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:
if self.skeleton_fn is None:
raise NotImplementedError
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(
id="llamacpp",
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,
)

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-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-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); }
.llama-id { overflow: hidden; text-overflow: ellipsis; }
.llama-health {
@ -270,3 +271,4 @@ button:hover { border-color: var(--accent); }
.llama-load select { max-width: 420px; }
.part-mounts { max-width: 420px; overflow: hidden; text-overflow: ellipsis; }
.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 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 %}
<div class="plugin-card">
<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 %}