docs: SYSTEM_MONITOR_API.md contract for the app + drop dead Pm2Logs.tsx

Full /api/system-monitor/* contract (stats snapshot incl. cpu/mem/disks/temp/
gpu/net/power, pm2, docker, and the two SSE log streams) with response shapes,
auth (Bearer or ?token=), owner-only note, and the net/power rate caveats — so
the app can implement the same views. Also removes the orphaned Pm2Logs.tsx
(superseded by LogStream) that a prior commit left tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 11:03:10 +00:00
co-authored by Claude Opus 4.8
parent f9752d2868
commit de8f70562b
2 changed files with 127 additions and 42 deletions
+127
View File
@@ -0,0 +1,127 @@
# 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 are gated to the platform owner. Non-owner accounts (e.g. music-app
users) are confined to `/api/auth` + `/api/music` and will get `403` here. The full **officer-mobile**
client (which authenticates as the owner from an owner-allowed origin) has access; the music app does not.
- 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, 0100
"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
```
@@ -1,42 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { ChevronLeft } from 'lucide-react';
const MAX_LINES = 1200;
// Live pm2 logs for one process (combined out+err), streamed over SSE from /system-monitor/pm2/logs.
export const Pm2Logs = ({ id, name, onBack }: { id: number; name: string; onBack: () => void }) => {
const { token } = useClient();
const [lines, setLines] = useState<string[]>([]);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setLines([]);
const es = new EventSource(`/api/system-monitor/pm2/logs?id=${id}&lines=150&token=${encodeURIComponent(token ?? '')}`);
es.onmessage = (ev) => setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), ev.data]);
return () => es.close();
}, [id, token]);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [lines]);
return (
<div className="flex h-full w-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-border p-3">
<button type="button" onClick={onBack} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronLeft size={16} /> pm2
</button>
<span className="font-mono text-sm text-foreground">{name}</span>
<span className="ml-auto inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
{lines.length === 0 ? (
<span className="text-muted-foreground">connecting</span>
) : (
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
)}
</div>
</div>
);
};