diff --git a/lib/api/index.js b/lib/api/index.js index a301669..1d97827 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -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'))); diff --git a/lib/api/routes/host.js b/lib/api/routes/host.js new file mode 100644 index 0000000..dc7efa6 --- /dev/null +++ b/lib/api/routes/host.js @@ -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()))); diff --git a/lib/host/resources.js b/lib/host/resources.js new file mode 100644 index 0000000..0cda1ac --- /dev/null +++ b/lib/host/resources.js @@ -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() }; +} diff --git a/tests/api/host.test.js b/tests/api/host.test.js new file mode 100644 index 0000000..e72b9dd --- /dev/null +++ b/tests/api/host.test.js @@ -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'); + }); +});