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:
@@ -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
5
lib/api/routes/host.js
Normal 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
42
lib/host/resources.js
Normal 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
22
tests/api/host.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user