system-monitor: /system-monitor route (CPU/mem/disk/processes) + page titles

- Backend GET /api/system-monitor/stats: one snapshot — CPU overall + per-core
  (two /proc/stat samples), memory + swap (/proc/meminfo), disks (df), load,
  uptime, top-20 processes (ps). Owner-only via the account gate.
- Frontend /system-monitor screen: polls every 2s; CPU/Memory/Disks cards with
  bars + per-core mini-bars, top-processes table. Nav dock "Monitor" item.
- Register /music and /system-monitor in usePageTitle RULES so the browser tab
  and editable header title update on those routes (/music was missing too).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 19:23:32 +00:00
co-authored by Claude Opus 4.8
parent 1d441e3aa1
commit d1e19d1473
8 changed files with 356 additions and 0 deletions
@@ -0,0 +1,143 @@
import os from 'node:os';
import { readFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createRouter } from '../../create-router';
// First-stab system monitor: a single /stats snapshot the frontend polls. Linux-only (reads /proc +
// shells out to df/ps); each piece degrades to empty/zero rather than failing the whole response.
const exec = promisify(execFile);
export const systemMonitorRouter = createRouter();
type CpuSample = { total: number; busy: number; perCore: { total: number; busy: number }[] };
async function readCpuSample(): Promise<CpuSample> {
const data = await readFile('/proc/stat', 'utf8');
const lines = data.split('\n').filter((l) => l.startsWith('cpu'));
const parse = (line: string) => {
const n = line.trim().split(/\s+/).slice(1).map(Number);
const total = n.reduce((a, b) => a + b, 0);
const idle = (n[3] ?? 0) + (n[4] ?? 0); // idle + iowait
return { total, busy: total - idle };
};
const agg = parse(lines[0] ?? 'cpu 0 0 0 0');
const perCore = lines.slice(1).map(parse);
return { total: agg.total, busy: agg.busy, perCore };
}
const pct = (busyDelta: number, totalDelta: number) =>
totalDelta > 0 ? Math.round((busyDelta / totalDelta) * 1000) / 10 : 0;
async function readMem() {
const data = await readFile('/proc/meminfo', 'utf8');
const map: Record<string, number> = {};
for (const line of data.split('\n')) {
const m = line.match(/^(\w+):\s+(\d+)/);
if (m) map[m[1]!] = Number(m[2]) * 1024; // kB → bytes
}
const total = map.MemTotal ?? 0;
const available = map.MemAvailable ?? map.MemFree ?? 0;
const used = Math.max(0, total - available);
const swapTotal = map.SwapTotal ?? 0;
const swapUsed = Math.max(0, swapTotal - (map.SwapFree ?? 0));
return {
totalBytes: total,
usedBytes: used,
freeBytes: available,
usedPct: total ? Math.round((used / total) * 1000) / 10 : 0,
swapTotalBytes: swapTotal,
swapUsedBytes: swapUsed,
};
}
async function readDisks() {
try {
const { stdout } = await exec('df', [
'-B1',
'--output=target,fstype,size,used,pcent',
'-x', 'tmpfs', '-x', 'devtmpfs', '-x', 'squashfs', '-x', 'overlay', '-x', 'efivarfs',
]);
return stdout
.trim()
.split('\n')
.slice(1)
.map((l) => {
const [mount, fsType, size, used, pcent] = l.trim().split(/\s+/);
return {
mount: mount ?? '',
fsType: fsType ?? '',
totalBytes: Number(size ?? 0),
usedBytes: Number(used ?? 0),
usedPct: Number((pcent ?? '0').replace('%', '')),
};
})
.filter((d) => d.totalBytes > 0);
} catch {
return [];
}
}
async function readProcesses() {
try {
const { stdout } = await exec('ps', ['-eo', 'pid,user:16,pcpu,pmem,comm', '--sort=-pcpu']);
return stdout
.trim()
.split('\n')
.slice(1, 21)
.map((l) => {
const [pid, user, cpu, mem, ...comm] = l.trim().split(/\s+/);
return {
pid: Number(pid ?? 0),
user: user ?? '',
cpuPct: Number(cpu ?? 0),
memPct: Number(mem ?? 0),
command: comm.join(' '),
};
});
} catch {
return [];
}
}
// GET /stats — one snapshot. CPU% comes from two /proc/stat samples ~120ms apart.
systemMonitorRouter.get('/stats', async (ctx) => {
const first = await readCpuSample().catch(() => null);
await new Promise((r) => setTimeout(r, 120));
const second = await readCpuSample().catch(() => null);
const cpuUsage =
first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
const perCore =
first && second
? second.perCore.map((c, i) => {
const a = first.perCore[i];
return a ? pct(c.busy - a.busy, c.total - a.total) : 0;
})
: [];
const [mem, disks, processes] = await Promise.all([
readMem().catch(() => null),
readDisks(),
readProcesses(),
]);
const cpus = os.cpus();
return ctx.json({
hostname: os.hostname(),
platform: `${os.type()} ${os.release()}`,
uptimeSec: Math.round(os.uptime()),
loadavg: os.loadavg(),
cpu: {
model: cpus[0]?.model?.trim() ?? 'unknown',
cores: cpus.length,
usagePct: cpuUsage,
perCore,
},
mem,
disks,
processes,
timestamp: Date.now(),
});
});