Implement active sessions plugin
This commit is contained in:
parent
814cd7f3e7
commit
ec9f92e147
7 changed files with 4178 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
174
app/plugins/sessions.py
Normal 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,
|
||||
)
|
||||
3963
opencode/011_opencode_session_plugin_sessions_2026-08-31.txt
Normal file
3963
opencode/011_opencode_session_plugin_sessions_2026-08-31.txt
Normal file
File diff suppressed because it is too large
Load diff
5
templates/plugins/sessions_skeleton.html
Normal file
5
templates/plugins/sessions_skeleton.html
Normal 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>
|
||||
23
templates/plugins/sessions_state.html
Normal file
23
templates/plugins/sessions_state.html
Normal 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>
|
||||
Loading…
Reference in a new issue