feat(host): /api/host CPU/mem/disk/net from /proc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-02 22:34:04 +10:00
parent 03803f39f0
commit 3492b24dac
4 changed files with 71 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ import { router as captureRouter } from './routes/capture.js';
import { spacesScopedRouter as companionRouter } from './routes/companion.js';
import { router as dashboardRouter } from './routes/dashboard.js';
import { router as weatherRouter } from './routes/weather.js';
import { router as hostRouter } from './routes/host.js';
export function mountApi(app) {
const api = Router();
@@ -57,6 +58,7 @@ export function mountApi(app) {
api.use('/capture', captureRouter);
api.use('/dashboard', dashboardRouter);
api.use('/weather', weatherRouter);
api.use('/host', hostRouter);
api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter);
api.use((_req, _res, next) => next(new NotFoundError('route not found')));

5
lib/api/routes/host.js Normal file
View File

@@ -0,0 +1,5 @@
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { snapshot } from '../../host/resources.js';
export const router = Router();
router.get('/', asyncWrap(async (_req, res) => res.json(await snapshot())));

42
lib/host/resources.js Normal file
View File

@@ -0,0 +1,42 @@
// lib/host/resources.js — reads CT 311's own /proc + statfs. Node 22 fs.statfs.
import { readFile } from 'node:fs/promises';
import { statfs } from 'node:fs/promises';
async function cpuSample() {
const line = (await readFile('/proc/stat', 'utf8')).split('\n')[0]; // "cpu u n s i ..."
const v = line.trim().split(/\s+/).slice(1).map(Number);
const idle = v[3] + (v[4] || 0);
const total = v.reduce((a, b) => a + b, 0);
return { idle, total };
}
export async function snapshot() {
// CPU%: two samples ~100ms apart.
const a = await cpuSample();
await new Promise(r => setTimeout(r, 100));
const b = await cpuSample();
const dTotal = b.total - a.total, dIdle = b.idle - a.idle;
const cpu_pct = dTotal > 0 ? Math.round((1 - dIdle / dTotal) * 100) : 0;
// Memory from /proc/meminfo (kB).
const mem = Object.fromEntries(
(await readFile('/proc/meminfo', 'utf8')).split('\n').filter(Boolean).map(l => {
const [k, val] = l.split(':'); return [k.trim(), parseInt(val) * 1024];
})
);
const total = mem.MemTotal, avail = mem.MemAvailable ?? mem.MemFree;
const memOut = { total, used: total - avail, pct: Math.round((1 - avail / total) * 100) };
// Disk for / via statfs.
const fs = await statfs('/');
const dTotalB = fs.blocks * fs.bsize, dFree = fs.bavail * fs.bsize;
const disk = { total: dTotalB, free: dFree, pct: Math.round((1 - dFree / dTotalB) * 100) };
// Net totals from /proc/net/dev (sum non-lo interfaces).
let rx = 0, tx = 0;
for (const l of (await readFile('/proc/net/dev', 'utf8')).split('\n')) {
const m = l.match(/^\s*([^:]+):\s+(\d+)(?:\s+\d+){7}\s+(\d+)/);
if (m && m[1].trim() !== 'lo') { rx += Number(m[2]); tx += Number(m[3]); }
}
return { cpu_pct, mem: memOut, disk, net: { rx_bytes: rx, tx_bytes: tx }, at: Date.now() };
}

22
tests/api/host.test.js Normal file
View File

@@ -0,0 +1,22 @@
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
let app, ownerHeaders;
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
describe('host api', () => {
it('401 without auth', async () => {
expect((await request(app).get('/api/host')).status).toBe(401);
});
it('returns cpu/mem/disk/net shape', async () => {
const res = await request(app).get('/api/host').set(ownerHeaders);
expect(res.status).toBe(200);
expect(res.body.cpu_pct).toBeGreaterThanOrEqual(0);
expect(res.body.cpu_pct).toBeLessThanOrEqual(100);
expect(res.body.mem.total).toBeGreaterThan(0);
expect(res.body.mem.used).toBeGreaterThanOrEqual(0);
expect(res.body.disk).toHaveProperty('pct');
expect(res.body.net).toHaveProperty('rx_bytes');
});
});