SPB Git

Repository Public

metrics.ts 6.0 KB 173 lines typescript

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
Files main
Menu

Theme