Repository Public
1/**2 * GET /host/metrics — real values only. Anything that cannot be measured is `null`.3 * cpuPct two `os.cpus()` samples (background sampler, 5 s)4 * load os.loadavg()5 * memUsedMb macOS: vm_stat (active + wired + compressor pages) × page size6 * Linux: /proc/meminfo (MemTotal − MemAvailable)7 * disk fs.statfs(dataDir)8 * sessions one `ps -axo pid=,ppid=,%cpu=,rss=` aggregated over each session's process tree9 */10import { execFile } from 'node:child_process';11import fs from 'node:fs/promises';12import os from 'node:os';13import type { RuntimePlugin } from './plugins.js';14import { getCore } from './core-util.js';1516const MB = 1024 * 1024;17const GB = 1024 * MB;18const round1 = (n: number): number => Math.round(n * 10) / 10;1920interface CpuSample {21 atMs: number;22 idle: number;23 total: number;24}2526function sampleCpu(): CpuSample {27 let idle = 0;28 let total = 0;29 for (const c of os.cpus()) {30 idle += c.times.idle;31 total += c.times.user + c.times.nice + c.times.sys + c.times.idle + c.times.irq;32 }33 return { atMs: Date.now(), idle, total };34}3536export function cpuPctBetween(a: CpuSample, b: CpuSample): number | null {37 const dTotal = b.total - a.total;38 if (!(dTotal > 0)) return null;39 const pct = (1 - (b.idle - a.idle) / dTotal) * 100;40 return round1(Math.min(100, Math.max(0, pct)));41}4243function run(file: string, args: string[]): Promise<string> {44 return new Promise((resolve, reject) => {45 execFile(file, args, { timeout: 5000, maxBuffer: 16 * MB, encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } }, (err, stdout) =>46 err ? reject(err) : resolve(stdout),47 );48 });49}5051/** Used memory in bytes from `vm_stat` output, or null if the expected fields are missing. */52export function parseVmStat(text: string): number | null {53 const pageSize = Number(/page size of (\d+) bytes/.exec(text)?.[1]);54 const pages = (label: string): number => Number(new RegExp(`^${label}:\\s+(\\d+)\\.?$`, 'm').exec(text)?.[1]);55 const active = pages('Pages active');56 const wired = pages('Pages wired down');57 const compressed = pages('Pages occupied by compressor');58 const used = (active + wired + compressed) * pageSize;59 return Number.isFinite(used) && used > 0 ? used : null;60}6162export function parseMeminfo(text: string): number | null {63 const kb = (label: string): number => Number(new RegExp(`^${label}:\\s+(\\d+) kB$`, 'm').exec(text)?.[1]);64 const used = (kb('MemTotal') - kb('MemAvailable')) * 1024;65 return Number.isFinite(used) && used >= 0 ? used : null;66}6768async function memUsedBytes(): Promise<number | null> {69 try {70 if (process.platform === 'darwin') return parseVmStat(await run('/usr/bin/vm_stat', []));71 if (process.platform === 'linux') return parseMeminfo(await fs.readFile('/proc/meminfo', 'utf8'));72 } catch {73 /* unknown */74 }75 return null;76}7778export interface ProcRow {79 pid: number;80 ppid: number;81 cpuPct: number;82 rssKb: number;83}8485export function parsePs(text: string): ProcRow[] {86 const rows: ProcRow[] = [];87 for (const line of text.split('\n')) {88 const f = line.trim().split(/\s+/);89 if (f.length < 4) continue;90 const row = { pid: Number(f[0]), ppid: Number(f[1]), cpuPct: Number((f[2] ?? '').replace(',', '.')), rssKb: Number(f[3]) };91 if (Number.isInteger(row.pid) && Number.isInteger(row.ppid) && Number.isFinite(row.cpuPct) && Number.isFinite(row.rssKb)) rows.push(row);92 }93 return rows;94}9596/** Sums %cpu and RSS over `rootPid` and all of its descendants; null if the root is not in the table. */97export function aggregateTree(rows: ProcRow[], rootPid: number): { cpuPct: number; rssMb: number } | null {98 const byPid = new Map<number, ProcRow>();99 const children = new Map<number, number[]>();100 for (const r of rows) {101 byPid.set(r.pid, r);102 const list = children.get(r.ppid) ?? [];103 list.push(r.pid);104 children.set(r.ppid, list);105 }106 if (!byPid.has(rootPid)) return null;107 let cpu = 0;108 let rssKb = 0;109 const seen = new Set<number>();110 const stack = [rootPid];111 while (stack.length > 0) {112 const pid = stack.pop();113 if (pid === undefined || seen.has(pid)) continue;114 seen.add(pid);115 const r = byPid.get(pid);116 if (r) {117 cpu += r.cpuPct;118 rssKb += r.rssKb;119 }120 for (const c of children.get(pid) ?? []) stack.push(c);121 }122 return { cpuPct: round1(cpu), rssMb: round1(rssKb / 1024) };123}124125const metricsPlugin: RuntimePlugin = async (app) => {126 const core = getCore(app);127 let prev = sampleCpu();128 let last = prev;129 const sampler = setInterval(() => {130 prev = last;131 last = sampleCpu();132 }, 5000);133 sampler.unref();134 app.addHook('onClose', async () => clearInterval(sampler));135136 app.get('/host/metrics', async () => {137 // Before the first background tick, compare against "now" (needs a little elapsed time to mean anything).138 let cpuPct = cpuPctBetween(prev, last);139 if (prev === last) {140 const now = sampleCpu();141 cpuPct = now.atMs - last.atMs >= 250 ? cpuPctBetween(last, now) : null;142 }143144 const [memUsed, disk, rows, psOut] = await Promise.all([145 memUsedBytes(),146 fs.statfs(core.config.dataDir).catch(() => null),147 core.tmux.listSessions().catch(() => []),148 run('/bin/ps', ['-axo', 'pid=,ppid=,%cpu=,rss=']).catch(() => null),149 ]);150 const procs = psOut === null ? null : parsePs(psOut);151 const sessions = rows152 .filter((r) => r.name.startsWith('cr-'))153 .map((r) => {154 const agg = procs !== null && !r.paneDead && r.panePid !== null ? aggregateTree(procs, r.panePid) : null;155 return { id: r.name.slice(3), cpuPct: agg?.cpuPct ?? null, rssMb: agg?.rssMb ?? null };156 });157 return {158 collectedAtMs: Date.now(),159 hostname: os.hostname(),160 cpuPct,161 load: os.loadavg(),162 memUsedMb: memUsed === null ? null : Math.round(memUsed / MB),163 memTotalMb: Math.round(os.totalmem() / MB),164 diskFreeGb: disk ? round1((disk.bavail * disk.bsize) / GB) : null,165 diskTotalGb: disk ? round1((disk.blocks * disk.bsize) / GB) : null,166 uptimeSec: Math.round(os.uptime()),167 sessions,168 };169 });170};171172export default metricsPlugin;173