Implement active sessions plugin

This commit is contained in:
Johannes Schriewer 2026-08-31 01:52:02 +02:00
parent 814cd7f3e7
commit ec9f92e147
7 changed files with 4178 additions and 7 deletions

View file

@ -8,7 +8,7 @@ A single-host computer dashboard and task manager: FastAPI (Python ≥ 3.12,
managed with `uv`) backend serving htmx + Chart.js HTML fragments to one
browser page. Binds to `127.0.0.1:8501`, no auth by design (see the README
security section). Tabs: Overview, Disks, Processes, Journal, Services
(systemd), Plugins (llama.cpp, sleep, LACT). Licensed under the Unlicense
(systemd), Plugins (llama.cpp, sleep, LACT, sessions). Licensed under the Unlicense
(see `LICENSE`).
## Commands
@ -67,7 +67,9 @@ agent's own shell command line and kills the session.
while the UI switch is on, reaps stale locks by `who` marker on startup)
+ lact plugin (shells out to `lact cli`: per-GPU profile dropdown with
set/reload, active profile polled every 5 s, GPU names shortened with
`app/utils/gpu.py:shorten` like the overview card).
`app/utils/gpu.py:shorten` like the overview card) + sessions plugin
(`loginctl --json=short list-sessions`, user class only; terminate
button runs `sudo loginctl terminate-session <id>`).
## Conventions

View file

@ -84,9 +84,12 @@ in the project root is read automatically (see `.env.example`).
a rescan for a `llama-server` running in router mode; **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); and
dashboard itself hold a sleep lock (released again on shutdown);
**GPU power profiles** (LACT): the active profile per GPU is polled, and
each GPU gets a profile dropdown with a *set* and a *reload* button.
each GPU gets a profile dropdown with a *set* and a *reload* button; and
**active sessions**: the user-class `loginctl` sessions (id, user, seat,
tty) with an active/idle status bubble per session and a *terminate*
button (runs `sudo loginctl terminate-session <id>`).
### llama.cpp router mode
@ -142,7 +145,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 + sleep + lact plugins
plugins/ # base.Plugin + llamacpp + sleep + lact + sessions plugins
templates/ # htmx fragments
static/ # css, js, vendored htmx + chart.js
deploy/ # dashboard.service

View file

@ -1,9 +1,10 @@
from app.plugins.base import Plugin
from app.plugins.lact import plugin as lact_plugin, router as lact_router
from app.plugins.llamacpp import plugin as llamacpp_plugin, router as llamacpp_router
from app.plugins.sessions import plugin as sessions_plugin, router as sessions_router
from app.plugins.sleep import plugin as sleep_plugin, router as sleep_router
PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin, lact_plugin]
ROUTERS = [llamacpp_router, sleep_router, lact_router]
PLUGINS: list[Plugin] = [llamacpp_plugin, sleep_plugin, lact_plugin, sessions_plugin]
ROUTERS = [llamacpp_router, sleep_router, lact_router, sessions_router]
__all__ = ["PLUGINS", "ROUTERS", "Plugin"]

174
app/plugins/sessions.py Normal file
View file

@ -0,0 +1,174 @@
import asyncio
import re
from typing import Annotated, Any, cast
from fastapi import APIRouter, Form
from fastapi.responses import HTMLResponse
from app.plugins.base import Plugin
from app.render import render
from app.utils.subprocess import run_async, run_json_async
router = APIRouter(prefix="/api/plugins/sessions", tags=["plugins"])
LIST_TIMEOUT = 5
TERMINATE_TIMEOUT = 5
SESSION_ID_RE = re.compile(r"^[A-Za-z0-9]+$")
_terminate_lock = asyncio.Lock()
async def _list() -> tuple[list[dict[str, Any]], str]:
"""List the loginctl sessions of the user class.
Runs `loginctl --json=short list-sessions` with a 5 s timeout (the
child is killed on timeout) and keeps only entries whose class is
"user" greeter, lock-screen, overlay, and manager sessions are
not shown. Every failure mode missing binary, timeout, non-zero
exit, bad JSON is returned as a short error string rather than
raised, so the UI can show a degraded state.
Returns:
(user-class session entries, "") on success, else ([], error
description).
"""
data, err = await run_json_async(
["loginctl", "--json=short", "list-sessions"], timeout=LIST_TIMEOUT
)
if err:
return [], err[:200]
if not isinstance(data, list):
return [], "unexpected loginctl output"
dicts: list[dict[str, Any]] = [e for e in cast("list[Any]", data) if isinstance(e, dict)]
items = [e for e in dicts if e.get("class") == "user"]
return items, ""
def _rows(entries: list[dict[str, Any]]) -> list[dict[str, str | bool]]:
"""Shape loginctl session entries into table rows for the UI.
seat and tty are null for seatless sessions (e.g. SSH) and render
as "". idle defaults to False when the field is missing or not a
boolean (newer systemd versions emit a boolean, older ones a
"yes"/"no" string that degrades to "active" here).
Args:
entries: user-class entries from _list().
Returns:
One row per session: id, user, seat, tty, idle.
"""
rows: list[dict[str, str | bool]] = []
for e in entries:
seat = e.get("seat")
tty = e.get("tty")
rows.append({
"id": str(e.get("session", "?")),
"user": str(e.get("user", "?")),
"seat": seat if isinstance(seat, str) and seat else "",
"tty": tty if isinstance(tty, str) and tty else "",
"idle": e.get("idle") is True,
})
return rows
def _context(entries: list[dict[str, Any]], error: str, message: str = "") -> dict[str, Any]:
"""Build the template context shared by the state and skeleton fragments.
Args:
entries: user-class entries from _list().
error: error string to display (from _list or a caller), "".
message: transient success message to display, "".
Returns:
Context with sessions rows, message, and error.
"""
return {
"sessions": _rows(entries),
"message": message,
"error": error,
}
async def _state(message: str = "", error: str = "") -> str:
"""Render the state fragment (polling view).
Args:
message: transient success message, or "".
error: error to display (overrides the _list error), or "".
Returns:
The rendered sessions_state.html fragment.
"""
entries, err = await _list()
if error:
err = error
return render("plugins/sessions_state.html", **_context(entries, err, message))
async def _skeleton(message: str = "", error: str = "") -> str:
"""Render the full skeleton fragment (initial + post-action view).
Args:
message: transient success message, or "".
error: error to display (overrides the _list error), or "".
Returns:
The rendered sessions_skeleton.html fragment.
"""
entries, err = await _list()
if error:
err = error
return render("plugins/sessions_skeleton.html", **_context(entries, err, message))
@router.get("/state")
async def state():
"""Poll endpoint: return the state fragment."""
return HTMLResponse(await _state())
@router.post("/terminate")
async def terminate(session: Annotated[str, Form()]):
"""Terminate one session and re-render the skeleton.
The requested session id is validated against a character whitelist
and against a fresh _list() (which also supplies the user name for
the result message) before `sudo loginctl terminate-session <id>` is
run; unknown values are reported in the fragment, not raised.
Serialized by a module-level lock so rapid double-clicks cannot
issue the same terminate twice.
Args:
session: session id from the form.
Returns:
The skeleton fragment with a success message or error.
"""
async with _terminate_lock:
if not SESSION_ID_RE.match(session):
return HTMLResponse(await _skeleton(error=f"invalid session id: {session}"))
entries, err = await _list()
if err:
return HTMLResponse(await _skeleton(error=err))
match = next((e for e in entries if str(e.get("session")) == session), None)
if match is None:
return HTMLResponse(await _skeleton(error=f"unknown session id: {session}"))
rc, _out, err_out = await run_async(
["sudo", "loginctl", "--json=short", "terminate-session", session],
timeout=TERMINATE_TIMEOUT,
)
if rc != 0:
return HTMLResponse(
await _skeleton(error=(err_out.strip() or f"terminate-session failed (rc={rc})")[:200])
)
user = str(match.get("user", "?"))
return HTMLResponse(await _skeleton(message=f"terminated session {session} ({user})"))
plugin = Plugin(
id="sessions",
title="Active Sessions",
description="User-class loginctl sessions and their idle state.",
skeleton_fn=_skeleton,
)

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,23 @@
{% if error %}<div class="alert">loginctl failed: {{ error }}</div>{% endif %}
{% if message %}<div class="notice">{{ message }}</div>{% endif %}
<div class="table-wrap">
<table class="table">
<thead><tr><th>id</th><th>user</th><th>seat</th><th>tty</th><th>status</th><th>actions</th></tr></thead>
<tbody>
{% for s in sessions %}
<tr>
<td class="mono">{{ s.id }}</td>
<td>{{ s.user }}</td>
<td class="mono muted">{{ s.seat }}</td>
<td class="mono muted">{{ s.tty }}</td>
<td><span class="badge {{ "badge-sleeping" if s.idle else "badge-active" }}">{{ "idle" if s.idle else "active" }}</span></td>
<td class="actions">
<button class="btn" hx-post="/api/plugins/sessions/terminate" hx-vals='{"session":"{{ s.id }}"}' hx-target="closest .plugin-body" hx-swap="innerHTML" hx-confirm="Terminate session {{ s.id }} ({{ s.user }})?">terminate</button>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">no user-class sessions</td></tr>
{% endfor %}
</tbody>
</table>
</div>