Implement sleep inhibitor plugin
This commit is contained in:
parent
a3b5bcf2c5
commit
e8a3171983
11 changed files with 268 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
app/main.py
12
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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
196
app/plugins/sleep.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<div id="plugins" class="panel">
|
||||
<div class="muted small">plugins load from <span class="mono">app/plugins/</span> — each module exposes a <span class="mono">Plugin</span> instance</div>
|
||||
{% for item in items %}
|
||||
<div class="plugin-card">
|
||||
<h3>{{ item.p.title }}</h3>
|
||||
|
|
|
|||
5
templates/plugins/sleep_skeleton.html
Normal file
5
templates/plugins/sleep_skeleton.html
Normal 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>
|
||||
30
templates/plugins/sleep_state.html
Normal file
30
templates/plugins/sleep_state.html
Normal 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 %}
|
||||
Loading…
Reference in a new issue