From e8a317198374f14d230e3c63fe03b032004016ba Mon Sep 17 00:00:00 2001 From: Johannes Schriewer Date: Sun, 30 Aug 2026 21:34:09 +0200 Subject: [PATCH] Implement sleep inhibitor plugin --- AGENTS.md | 7 +- README.md | 7 +- app/main.py | 12 +- app/plugins/__init__.py | 5 +- app/plugins/base.py | 10 ++ app/plugins/llamacpp.py | 2 +- app/plugins/sleep.py | 196 ++++++++++++++++++++++++++ static/css/style.css | 2 + templates/plugins.html | 1 - templates/plugins/sleep_skeleton.html | 5 + templates/plugins/sleep_state.html | 30 ++++ 11 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 app/plugins/sleep.py create mode 100644 templates/plugins/sleep_skeleton.html create mode 100644 templates/plugins/sleep_state.html diff --git a/AGENTS.md b/AGENTS.md index 1b7196b..23c39aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index afc7b63..c9551b3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/main.py b/app/main.py index 23cc00c..5ac2967 100644 --- a/app/main.py +++ b/app/main.py @@ -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: diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 49507e8..e0b76d7 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -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"] diff --git a/app/plugins/base.py b/app/plugins/base.py index 1506343..6e17788 100644 --- a/app/plugins/base.py +++ b/app/plugins/base.py @@ -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() diff --git a/app/plugins/llamacpp.py b/app/plugins/llamacpp.py index 6d955c8..6b80b85 100644 --- a/app/plugins/llamacpp.py +++ b/app/plugins/llamacpp.py @@ -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, ) diff --git a/app/plugins/sleep.py b/app/plugins/sleep.py new file mode 100644 index 0000000..795c1cf --- /dev/null +++ b/app/plugins/sleep.py @@ -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, +) diff --git a/static/css/style.css b/static/css/style.css index 385a0dc..0f5b493 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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); } diff --git a/templates/plugins.html b/templates/plugins.html index 4a678ca..768704e 100644 --- a/templates/plugins.html +++ b/templates/plugins.html @@ -1,5 +1,4 @@
-
plugins load from app/plugins/ — each module exposes a Plugin instance
{% for item in items %}

{{ item.p.title }}

diff --git a/templates/plugins/sleep_skeleton.html b/templates/plugins/sleep_skeleton.html new file mode 100644 index 0000000..396d4f2 --- /dev/null +++ b/templates/plugins/sleep_skeleton.html @@ -0,0 +1,5 @@ +
+
+ {% include "plugins/sleep_state.html" %} +
+
diff --git a/templates/plugins/sleep_state.html b/templates/plugins/sleep_state.html new file mode 100644 index 0000000..6f21f36 --- /dev/null +++ b/templates/plugins/sleep_state.html @@ -0,0 +1,30 @@ +{% if error %}
systemd-inhibit failed: {{ error }}
{% endif %} +{% if message %}
{{ message }}
{% endif %} +
+ + {{ "sleep blocked" if verdict == "blocked" else "sleep allowed" }} + block-mode locks only — delay-mode locks are ignored +
+ +{% if inhibitors %} +
+ + + + {% for e in inhibitors %} + + + + + + + {% endfor %} + +
whowhatwhymode
{{ e.who }}{% if e.proc %} {{ e.proc }}{% endif %}{% for w in e.what.split(":") if w %}{{ w }} {% endfor %}{{ e.why }}{{ e.mode }}
+
+{% else %} +
no active block-mode inhibitors — the system may sleep
+{% endif %}