Files
txoko-dashboard/system-metrics.js
T

370 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Txoko Node - System Metrics Server (v2 — ligero)
* Expone métricas del sistema via HTTP en localhost.
*
* Cambios v2 respecto a v1:
* - CPU medida leyendo /proc/stat dos veces con delta (sin spawnear `top`)
* - Por-núcleo con uso ACTUAL real (antes era el acumulado desde el arranque)
* - RPC de Bitcoin Core por HTTP nativo (sin `curl` → credenciales fuera de `ps`)
* - Peers contados con getnetworkinfo (sin getpeerinfo, que es enorme)
* - Caché con TTL: mide una vez cada pocos segundos, sirva a N navegadores
* - Todo asíncrono: nada bloquea el servidor
*
* Setup: ninguno. Sin dependencias — solo Node.js.
* node system-metrics.js
*
* Acceso: http://127.0.0.1:4082
*/
const { exec } = require("child_process");
const fs = require("fs");
const os = require("os");
const http = require("http");
// ── CONFIGURACIÓN LOCAL ─────────────────────────────────────────────────────
const RPC_HOST = "127.0.0.1";
const RPC_PORT = 8332;
const RPC_USER = "TU_RPC_USER"; // bitcoin.conf → rpcuser
const RPC_PASS = "TU_RPC_PASSWORD"; // bitcoin.conf → rpcpassword
const PORT = 4082;
const HOST = "127.0.0.1";
// ── FIN CONFIGURACIÓN ───────────────────────────────────────────────────────
// ── Helpers asíncronos ──────────────────────────────────────────────────────
const readFile = (path) => {
try { return fs.readFileSync(path, "utf8").trim(); } catch { return null; }
};
const sh = (cmd) => new Promise((resolve) => {
exec(cmd, { timeout: 5000 }, (err, stdout) => resolve(err ? null : stdout.toString().trim()));
});
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ── Caché con TTL y coalescencia de peticiones ──────────────────────────────
// Mide una sola vez por ventana; si llegan peticiones mientras se calcula,
// esperan a la misma promesa en vez de lanzar otro cálculo.
const cache = new Map(); // key -> {ts, data, inflight}
async function cached(key, ttlMs, fn) {
const now = Date.now();
const entry = cache.get(key);
if (entry) {
if (entry.inflight) return entry.inflight;
if (now - entry.ts < ttlMs) return entry.data;
}
const inflight = fn().then(data => {
cache.set(key, { ts: Date.now(), data, inflight: null });
return data;
}).catch(err => {
cache.delete(key);
throw err;
});
cache.set(key, { ts: now, data: entry?.data, inflight });
return inflight;
}
// ── CPU: /proc/stat con delta entre dos lecturas (sin procesos externos) ────
function readProcStat() {
const stat = readFile("/proc/stat");
if (!stat) return null;
const out = {};
for (const line of stat.split("\n")) {
const m = line.match(/^(cpu\d*)\s+(.+)$/);
if (!m) continue;
const nums = m[2].trim().split(/\s+/).map(Number);
const idle = nums[3] + (nums[4] || 0); // idle + iowait
const total = nums.reduce((a, b) => a + b, 0);
out[m[1]] = { idle, total };
}
return out;
}
async function getCpuUsage() {
const s1 = readProcStat();
await sleep(500);
const s2 = readProcStat();
if (!s1 || !s2) return { total: 0, cores: [] };
const usage = (a, b) => {
const dt = b.total - a.total;
const di = b.idle - a.idle;
if (dt <= 0) return 0;
return Math.min(100, Math.max(0, Math.round(((dt - di) / dt) * 100)));
};
const total = usage(s1.cpu, s2.cpu);
const cores = Object.keys(s2)
.filter(k => /^cpu\d+$/.test(k))
.sort((a, b) => parseInt(a.slice(3)) - parseInt(b.slice(3)))
.map((k, i) => ({ core: i, usage: s1[k] ? usage(s1[k], s2[k]) : 0 }));
return { total, cores };
}
// ── Temperatura: solo lecturas de /sys (sin `sensors`) ─────────────────────
function getCpuTemp() {
const paths = [
"/sys/class/thermal/thermal_zone0/temp",
"/sys/class/thermal/thermal_zone1/temp",
"/sys/class/hwmon/hwmon0/temp1_input",
"/sys/class/hwmon/hwmon1/temp1_input",
"/sys/class/hwmon/hwmon2/temp1_input",
];
for (const path of paths) {
const val = readFile(path);
if (val) {
const temp = parseInt(val) / 1000;
if (temp > 0 && temp < 150) return parseFloat(temp.toFixed(1));
}
}
return null;
}
// ── RAM desde /proc/meminfo ─────────────────────────────────────────────────
function getRam() {
try {
const meminfo = readFile("/proc/meminfo");
if (!meminfo) throw new Error("no meminfo");
const get = key => {
const m = meminfo.match(new RegExp(`^${key}:\\s+(\\d+)`, "m"));
return m ? parseInt(m[1]) * 1024 : 0;
};
const total = get("MemTotal");
const free = get("MemFree");
const buffers = get("Buffers");
const cached_ = get("Cached");
const reclaimable = get("SReclaimable");
const swapTotal = get("SwapTotal");
const swapFree = get("SwapFree");
const used = total - free - buffers - cached_ - reclaimable;
const swapUsed = swapTotal - swapFree;
const gb = v => parseFloat((v / 1e9).toFixed(1));
return {
total_gb: gb(total), used_gb: gb(used), free_gb: gb(free),
cached_gb: gb(cached_ + buffers + reclaimable),
percent: Math.round((used / total) * 100),
swap_total_gb: gb(swapTotal), swap_used_gb: gb(swapUsed),
swap_percent: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0,
};
} catch {
const total = os.totalmem(), free = os.freemem(), used = total - free;
return {
total_gb: parseFloat((total / 1e9).toFixed(1)),
used_gb: parseFloat((used / 1e9).toFixed(1)),
free_gb: parseFloat((free / 1e9).toFixed(1)),
percent: Math.round((used / total) * 100),
};
}
}
// ── Disco ───────────────────────────────────────────────────────────────────
async function getDisk() {
const result = await sh("df -h / | tail -1");
if (!result) return null;
const parts = result.split(/\s+/);
return { total: parts[1], used: parts[2], free: parts[3], percent: parts[4] };
}
// ── Procesos destacados (nombre limpio del binario) ────────────────────────
async function getProcesses() {
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
if (!result) return [];
return result.split("\n").map(line => {
const parts = line.trim().split(/\s+/);
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
// python...), añade el basename del script que ejecuta.
const exe = (parts[10] || "").split("/").pop();
const interp = /^(node|python\d?|bash|sh|perl|ruby|java)$/.test(exe);
let command = exe;
if (interp) {
// Saltar flags (--max-old-space-size=…, -e, etc.) y quedarse con el
// primer argumento "real": el script (suele acabar en .js/.py o ser ruta)
const args = parts.slice(11);
const script = args.find(a => a && !a.startsWith("-"));
if (script) {
const name = script.split("/").pop();
command = `${exe} (${name})`;
}
}
command = command.slice(0, 24);
return {
user: parts[0],
cpu: parseFloat(parts[2]),
mem: parseFloat(parts[3]),
command,
};
}).filter(p => p.mem > 0.5);
}
// ── Load / Uptime ───────────────────────────────────────────────────────────
function getLoad() {
const load = os.loadavg();
return { "1m": load[0].toFixed(2), "5m": load[1].toFixed(2), "15m": load[2].toFixed(2) };
}
function getUptime() {
const s = os.uptime();
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
}
// ── Bitcoin Core RPC por HTTP nativo (sin curl, sin credenciales en ps) ────
function rpcCall(method, params = []) {
const body = JSON.stringify({ jsonrpc: "1.0", id: "txoko", method, params });
const auth = Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString("base64");
return new Promise((resolve, reject) => {
const req = http.request({
host: RPC_HOST, port: RPC_PORT, method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Basic ${auth}` },
timeout: 8000,
}, res => {
let data = "";
res.on("data", c => data += c);
res.on("end", () => {
try {
const parsed = JSON.parse(data);
if (parsed.error) reject(new Error(parsed.error.message || "RPC error"));
else resolve(parsed.result);
} catch (e) { reject(e); }
});
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("RPC timeout")); });
req.write(body);
req.end();
});
}
async function getBitcoinInfo() {
try {
const [info, netinfo, uptime] = await Promise.all([
rpcCall("getblockchaininfo"),
rpcCall("getnetworkinfo"),
rpcCall("uptime"),
]);
if (!info || !netinfo) return null;
// connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no
let inbound = netinfo.connections_in;
let outbound = netinfo.connections_out;
if (inbound === undefined || outbound === undefined) {
const peers = await rpcCall("getpeerinfo").catch(() => null);
inbound = peers ? peers.filter(p => p.inbound).length : 0;
outbound = peers ? peers.filter(p => !p.inbound).length : 0;
}
return {
version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`,
blocks: info.blocks,
headers: info.headers,
synced: info.verificationprogress > 0.9999,
progress: (info.verificationprogress * 100).toFixed(2),
size_gb: (info.size_on_disk / 1e9).toFixed(1),
peers: netinfo.connections,
inbound, outbound,
uptime_sec: uptime,
chain: info.chain,
};
} catch { return null; }
}
// ── Logs ────────────────────────────────────────────────────────────────────
async function getLogs(service, lines = 50) {
const result = await sh(`journalctl -u ${service} -n ${lines} --no-pager --output=short-iso 2>/dev/null`);
if (!result) return [];
return result.split("\n").filter(Boolean).reverse().map(line => {
let level = "info";
if (/error/i.test(line)) level = "error";
else if (/warn/i.test(line)) level = "warn";
return { line, level };
});
}
// ── Handlers (caché por ruta: el trabajo se hace 1 vez por ventana) ────────
const routes = {
"/system/info": async () => cached("info", 3000, async () => {
const [cpu, disk, processes] = await Promise.all([
getCpuUsage(), getDisk(), getProcesses(),
]);
return {
cpu: {
usage: cpu.total,
cores: cpu.cores,
temp: getCpuTemp(),
count: os.cpus().length,
model: os.cpus()[0]?.model?.trim() || "—",
},
ram: getRam(),
disk,
load: getLoad(),
processes,
uptime: getUptime(),
os: `${os.type()} ${os.release()}`,
};
}),
"/system/bitcoin": async () => {
const info = await cached("bitcoin", 5000, getBitcoinInfo);
if (!info) return { __status: 503, error: "No se pudo conectar a Bitcoin Core" };
return info;
},
"/system/logs/bitcoin": async (query) => {
const lines = parseInt(query.get("lines")) || 50;
const logs = await cached(`logs-btc-${lines}`, 10000, () => getLogs("bitcoind", lines));
return { logs };
},
"/system/logs/fulcrum": async (query) => {
const lines = parseInt(query.get("lines")) || 50;
const logs = await cached(`logs-ful-${lines}`, 10000, () => getLogs("fulcrum", lines));
return { logs };
},
"/system/health": async () => ({ status: "ok", timestamp: Date.now() }),
};
// ── Servidor HTTP nativo (sin dependencias) ─────────────────────────────────
const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Content-Type", "application/json");
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const handler = routes[url.pathname];
if (!handler) {
res.writeHead(404);
return res.end(JSON.stringify({ error: "Not found" }));
}
try {
const data = await handler(url.searchParams);
if (data && data.__status) {
const { __status, ...body } = data;
res.writeHead(__status);
return res.end(JSON.stringify(body));
}
res.writeHead(200);
res.end(JSON.stringify(data));
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: e.message || "Internal error" }));
}
});
// ── Start ───────────────────────────────────────────────────────────────────
server.listen(PORT, HOST, () => {
console.log(`
₿ Txoko System Metrics v2
─────────────────────────────────
Escuchando en http://${HOST}:${PORT}
Solo accesible desde localhost
Caché: info 3s · bitcoin 5s · logs 10s
─────────────────────────────────
GET /system/info → CPU, RAM, disco, procesos
GET /system/bitcoin → Bitcoin Core info
GET /system/logs/bitcoin → logs bitcoind
GET /system/logs/fulcrum → logs fulcrum
`);
});