# 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 — `EventSource` can't set headers).
- **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and
  therefore never grantable — a non-owner account gets `403` here 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/json` except the two `/logs` endpoints, which are `text/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.

```jsonc
{
  "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.*BytesPerSec` and `power.cpuWatts` are **deltas since the previous `/stats` call**. The **first**
  call returns `0`/`null` for these; steady-interval polling gives stable numbers.
- `cpuWatts` is usually `null` — RAPL `energy_uj` is root-only unless a udev rule opens it. `gpuWatts` works.
- Any section can be `null` on hardware that doesn't expose it — render defensively.

---

## `GET /api/system-monitor/pm2`

```jsonc
{
  "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`

```jsonc
{
  "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, default `100`, max `1000`.
- 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`), default `100`, max `1000`.
- Source: `docker logs -f --tail <n> <id>` (combined stdout+stderr).

```js
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
```
