feat(node-agent): heartbeat agent — CPU/mem stats, health endpoint, bearer token auth

This commit is contained in:
Zac Gaetano 2026-05-20 13:48:18 -04:00
parent 0bc1ac9161
commit c5a358888b

View file

@ -0,0 +1,103 @@
import express from 'express';
import os from 'os';
const MAM_API_URL = (process.env.MAM_API_URL || 'http://localhost:3000').replace(/\/$/, '');
const NODE_TOKEN = process.env.NODE_TOKEN || '';
const NODE_ROLE = process.env.NODE_ROLE || 'worker';
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '3002', 10);
const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 10);
const VERSION = '1.0.0';
const app = express();
app.use(express.json());
// ── Health ────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => {
res.json({
ok: true,
hostname: os.hostname(),
uptime: Math.round(process.uptime()),
version: VERSION,
role: NODE_ROLE,
});
});
// ── CPU sampling (500ms window) ───────────────────────────────────────────
function sampleCpu() {
return new Promise(resolve => {
const s1 = os.cpus();
setTimeout(() => {
const s2 = os.cpus();
let idle = 0, total = 0;
s2.forEach((cpu, i) => {
for (const t of Object.keys(cpu.times)) {
const d = cpu.times[t] - (s1[i]?.times[t] ?? 0);
total += d;
if (t === 'idle') idle += d;
}
});
resolve(total > 0 ? Math.round((1 - idle / total) * 10000) / 100 : 0);
}, 500);
});
}
function getIp() {
for (const ifaces of Object.values(os.networkInterfaces())) {
const hit = (ifaces || []).find(a => a.family === 'IPv4' && !a.internal);
if (hit) return hit.address;
}
return null;
}
// ── Heartbeat ─────────────────────────────────────────────────────────────
async function heartbeat() {
const cpu_usage = await sampleCpu();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const ip_address = getIp();
const payload = {
hostname: os.hostname(),
ip_address,
role: NODE_ROLE,
version: VERSION,
api_url: `http://${ip_address || os.hostname()}:${AGENT_PORT}`,
cpu_usage,
mem_used_mb: Math.round((totalMem - freeMem) / 1048576),
mem_total_mb: Math.round(totalMem / 1048576),
};
const headers = { 'Content-Type': 'application/json' };
if (NODE_TOKEN) headers['Authorization'] = `Bearer ${NODE_TOKEN}`;
try {
const res = await fetch(`${MAM_API_URL}/api/v1/cluster/heartbeat`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8000),
});
if (res.ok) {
process.stdout.write(
`[hb] ${payload.hostname} cpu=${cpu_usage}% ` +
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB\n`
);
} else {
const txt = await res.text().catch(() => '');
console.error(`[hb] ${res.status} ${txt.slice(0, 120)}`);
}
} catch (err) {
console.error(`[hb] failed — ${err.message}`);
}
}
heartbeat();
setInterval(heartbeat, HEARTBEAT_MS);
app.listen(AGENT_PORT, () => {
console.log(`wild-dragon-node-agent v${VERSION}`);
console.log(` Listening :${AGENT_PORT}`);
console.log(` Primary ${MAM_API_URL}`);
console.log(` Role ${NODE_ROLE}`);
console.log(` Heartbeat every ${HEARTBEAT_MS / 1000}s`);
});