diff --git a/SYSTEM_MONITOR_API.md b/SYSTEM_MONITOR_API.md new file mode 100644 index 00000000..97b73283 --- /dev/null +++ b/SYSTEM_MONITOR_API.md @@ -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 `**, or as **`?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, 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: `** 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=&lines=` +- `id` — **numeric** pm2 id from `/pm2` (required). +- `lines` — initial backlog, default `100`, max `1000`. +- Source: `pm2 logs --raw` (combined stdout+stderr, follows live). The first frames include a short + pm2 `[TAILING] …` header. + +### `GET /api/system-monitor/docker/logs?id=&lines=` +- `id` — container id or name from `/docker` (charset-validated). +- `lines` — initial backlog (`--tail`), default `100`, max `1000`. +- Source: `docker logs -f --tail ` (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 +``` diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2Logs.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2Logs.tsx deleted file mode 100644 index 1ec3607e..00000000 --- a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2Logs.tsx +++ /dev/null @@ -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([]); - const scrollRef = useRef(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 ( -
-
- - {name} - -
-
- {lines.length === 0 ? ( - connecting… - ) : ( - lines.map((l, i) =>
{l}
) - )} -
-
- ); -};