CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.4 KiB
5.4 KiB
System Monitor API (/api/system-monitor/*)
Everything the /system-monitor web screen renders, for building the same in the app.
Auth & access
- Send the JWT as
Authorization: Bearer <token>, or as?token=<token>in the query string (required for the SSE endpoints —EventSourcecan't set headers). - Owner-only. These routes belong to the
server-admincapability, which iskind: 'admin'and therefore never grantable — a non-owner account gets403here whatever its role. The full officer-mobile client (which authenticates as the owner) has access; the music app does not. - Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were
confined to a hardcoded
/api/auth+/api/music. That list is gone, replaced by per-role capability grants. The outcome for these routes is unchanged — still owner-only — but the reason is now the capability's kind, not a two-element array. - All responses are
application/jsonexcept the two/logsendpoints, which aretext/event-stream.
GET /api/system-monitor/stats
One full snapshot. Poll it on a steady interval (the web client uses 2 s) — a few fields are rates computed from the delta since your previous call (see notes), so a steady cadence matters.
{
"hostname": "alpha",
"platform": "Linux 6.8.0-136-generic",
"uptimeSec": 614031,
"loadavg": [0.59, 0.60, 0.81], // 1 / 5 / 15 min
"cpu": {
"model": "AMD Ryzen 9 7940HS w/ Radeon 780M Graphics",
"cores": 16,
"usagePct": 2.6, // overall, 0–100
"perCore": [3.1, 0.0, 12.4, ...] // length === cores
},
"mem": { // null if unreadable
"totalBytes": 65100000000, "usedBytes": 26800000000, "freeBytes": 38300000000,
"usedPct": 41.2, "swapTotalBytes": 0, "swapUsedBytes": 0
},
"disks": [ // real mounts (tmpfs/overlay excluded)
{ "mount": "/", "fsType": "ext4", "totalBytes": 0, "usedBytes": 0, "usedPct": 32 }
],
"processes": [ // top 20 by CPU
{ "pid": 1234, "user": "pastilhas", "cpuPct": 21.8, "memPct": 1.2, "command": "radicle-node" }
],
"temp": { // null if no hwmon
"cpuC": 56.0, // chosen CPU sensor, °C (null if none matched)
"cpuLabel": "k10temp · Tctl",
"sensors": [ { "name": "amdgpu", "label": "edge", "celsius": 55.0 }, ... ] // every hwmon temp
},
"gpu": { // null if no /sys/class/drm gpu_busy_percent
"busyPct": 0, "vramUsedBytes": 2092957696, "vramTotalBytes": 2147483648
},
"net": { // null if /proc/net/dev unreadable
"rxBytesPerSec": 0, "txBytesPerSec": 0, // aggregate (excludes lo)
"interfaces": [ { "name": "eth0", "rxBytesPerSec": 0, "txBytesPerSec": 0 } ] // active only, busiest first
},
"power": { // null if unreadable
"cpuWatts": null, // RAPL is root-only by default → usually null
"gpuWatts": 38.1 // amdgpu hwmon
},
"timestamp": 1785150000000
}
Notes
net.*BytesPerSecandpower.cpuWattsare deltas since the previous/statscall. The first call returns0/nullfor these; steady-interval polling gives stable numbers.cpuWattsis usuallynull— RAPLenergy_ujis root-only unless a udev rule opens it.gpuWattsworks.- Any section can be
nullon hardware that doesn't expose it — render defensively.
GET /api/system-monitor/pm2
{
"processes": [
{ "id": 0, // pm2 id (pm_id) — use this for the logs endpoint
"name": "officer",
"status": "online", // online | stopped | errored | …
"pid": 3339851, // OS pid, or null
"cpuPct": 0,
"memBytes": 10354688,
"restarts": 44,
"uptimeMs": 420000 } // 0 unless status === "online"
],
"error": "…" // present only if pm2 couldn't be read
}
GET /api/system-monitor/docker
{
"containers": [
{ "id": "abc123def456", // short id (12 chars) — use for the logs endpoint
"name": "jellyfin",
"image": "jellyfin/jellyfin",
"state": "running", // running | exited | …
"status": "Up 3 hours",
"ports": "0.0.0.0:9301->8096/tcp" }
],
"error": "…"
}
Live logs (SSE)
Both stream one data: <log line> frame per line, plus : hb heartbeat comments every 15 s. The
server kills the underlying tail when the connection closes. Open with EventSource using ?token=.
GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n>
id— numeric pm2 id from/pm2(required).lines— initial backlog, default100, max1000.- Source:
pm2 logs <id> --raw(combined stdout+stderr, follows live). The first frames include a short pm2[TAILING] …header.
GET /api/system-monitor/docker/logs?id=<container>&lines=<n>
id— container id or name from/docker(charset-validated).lines— initial backlog (--tail), default100, max1000.- Source:
docker logs -f --tail <n> <id>(combined stdout+stderr).
const es = new EventSource(`/api/system-monitor/pm2/logs?id=0&lines=150&token=${token}`);
es.onmessage = (e) => appendLine(e.data);
// close es to stop the tail