La columna %CPU de ps aux es la media desde que arrancó el proceso, no el consumo actual: un proceso que trabajó mucho hace días seguía apareciendo alto para siempre. Se detectó porque los porcentajes no cambiaban nunca entre lecturas mientras la CPU global sí variaba. Ahora se mide con dos lecturas de /proc/PID/stat separadas 500 ms, el mismo método que ya usaba getCpuUsage para el total. La UI muestra el % sobre el total de la máquina, con el % por núcleo y la media de ps en el tooltip. Verificado con carga artificial: 100% de un núcleo (25% de 4) frente al 152% que reportaba ps, imposible para un proceso de un solo hilo. Además, el UTXO Map era el único punto que se saltaba la caché usando fetchWithTimeout directo. Ahora pasa por get().
414 lines
16 KiB
JavaScript
414 lines
16 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) ────────────────────────
|
|
// Tiempo de CPU consumido por un proceso, en ticks, desde /proc/PID/stat.
|
|
// Campos 14 (utime) y 15 (stime). El nombre del proceso va entre paréntesis y
|
|
// puede contener espacios, así que se corta por el ÚLTIMO ')' antes de partir.
|
|
function readProcCpuTicks(pid) {
|
|
try {
|
|
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
const resto = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
// resto[0] es el campo 3 (estado), así que utime=campo14 → resto[11]
|
|
const utime = parseInt(resto[11], 10);
|
|
const stime = parseInt(resto[12], 10);
|
|
if (Number.isNaN(utime) || Number.isNaN(stime)) return null;
|
|
return utime + stime;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// OJO con la columna %CPU de `ps`: NO es el consumo actual, sino la media del
|
|
// proceso desde que arrancó (tiempo de CPU / tiempo de vida). Un proceso que
|
|
// trabajó mucho al principio y ahora está ocioso sigue mostrando un número
|
|
// alto días después, y se lee como si estuviera saturando la máquina.
|
|
// Aquí se mide de verdad: dos lecturas de /proc separadas 500 ms, el mismo
|
|
// método que ya usa getCpuUsage para el total del sistema. Las dos esperas
|
|
// corren en paralelo (van dentro del mismo Promise.all), así que no cuesta
|
|
// tiempo extra.
|
|
async function getProcesses() {
|
|
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
|
if (!result) return [];
|
|
const filas = 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 {
|
|
pid: parts[1],
|
|
user: parts[0],
|
|
cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps)
|
|
mem: parseFloat(parts[3]),
|
|
command,
|
|
};
|
|
}).filter(p => p.mem > 0.5);
|
|
|
|
const ticks1 = filas.map(p => readProcCpuTicks(p.pid));
|
|
await sleep(500);
|
|
const ticks2 = filas.map(p => readProcCpuTicks(p.pid));
|
|
|
|
const USER_HZ = 100; // estándar en Linux
|
|
const VENTANA_S = 0.5;
|
|
const nucleos = os.cpus().length || 1;
|
|
|
|
return filas.map((p, i) => {
|
|
let cpu = null, cpuSistema = null;
|
|
if (ticks1[i] !== null && ticks2[i] !== null) {
|
|
const seg = (ticks2[i] - ticks1[i]) / USER_HZ;
|
|
// % de UN núcleo (criterio de top/ps: puede pasar de 100 si va en varios)
|
|
cpu = Math.max(0, Math.round((seg / VENTANA_S) * 1000) / 10);
|
|
// % del total de la máquina, que es lo que suele querer saberse
|
|
cpuSistema = Math.round((cpu / nucleos) * 10) / 10;
|
|
}
|
|
return { user: p.user, command: p.command, mem: p.mem, cpu, cpuSistema, cpuMedia: p.cpuMedia };
|
|
});
|
|
}
|
|
|
|
// ── 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
|
|
`);
|
|
});
|