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:
@@ -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(),
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user