diff --git a/.gitignore b/.gitignore index 2bc7377..d5319f0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ logs/ node_modules/ npm-debug.log* txoko-roadmap.md +TRASPASO.md diff --git a/dashboard.html b/dashboard.html index becac09..ec9c300 100644 --- a/dashboard.html +++ b/dashboard.html @@ -1285,6 +1285,85 @@ return result; }; + // ── Informe de wallet completo ──────────────────────────────────────── + // Analiza el conjunto de txs del wallet: vinculación (CIOH), reutilización + // y un resumen de salud. Funciona sobre datos ya traídos, en memoria. + function buildWalletReport(activeAddrs, txs, myAddrSet) { + // 1) Reutilización: direcciones propias que aparecen en más de una tx + const addrUseCount = new Map(); + for (const tx of txs) { + const touched = new Set(); + for (const vin of (tx.vin||[])) { + const a = vin.prevout?.scriptpubkey_address; + if (a && myAddrSet.has(a)) touched.add(a); + } + for (const vout of (tx.vout||[])) { + const a = vout.scriptpubkey_address; + if (a && myAddrSet.has(a)) touched.add(a); + } + for (const a of touched) addrUseCount.set(a, (addrUseCount.get(a)||0) + 1); + } + const reusedAddrs = [...addrUseCount.entries()].filter(([,c]) => c > 2); + // Nota: umbral 2 porque una dirección normal aparece en 2 txs como mínimo + // (la que recibe y la que gasta). Más de 2 = reutilización real. + + // 2) Clusters de vinculación por CIOH (common-input-ownership): + // si una tx gasta varias direcciones propias como inputs, un observador + // asume que pertenecen al mismo dueño. Union-Find sobre direcciones. + const parent = new Map(); + const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; }; + const union = (a, b) => { const ra=find(a), rb=find(b); if (ra!==rb) parent.set(ra, rb); }; + for (const a of myAddrSet) parent.set(a, a); + + const linkReasons = []; + for (const tx of txs) { + const ownInputs = (tx.vin||[]) + .map(v => v.prevout?.scriptpubkey_address) + .filter(a => a && myAddrSet.has(a)); + const uniq = [...new Set(ownInputs)]; + if (uniq.length > 1) { + for (let i = 1; i < uniq.length; i++) union(uniq[0], uniq[i]); + linkReasons.push({ txid: tx.txid, addrs: uniq, reason: "compartieron inputs en una misma transacción (CIOH)" }); + } + } + + const clusterMap = new Map(); + const linked = new Set(linkReasons.flatMap(l => l.addrs)); + for (const a of linked) { + const r = find(a); + if (!clusterMap.has(r)) clusterMap.set(r, []); + clusterMap.get(r).push(a); + } + const clusters = [...clusterMap.values()].filter(c => c.length > 1) + .sort((a,b) => b.length - a.length); + + // 3) Historial: cada tx con su banda + const history = txs.map(tx => { + let band = "—", score = null; + try { const a = analyzeTx(tx); band = a.band; score = a.score; } catch {} + return { txid: tx.txid, band, score, time: tx.status?.block_time || 0, confirmed: !!tx.status?.confirmed }; + }).sort((a,b) => b.time - a.time); + + // 4) Salud general + const totalTxs = txs.length; + const reuseRatio = activeAddrs.length ? reusedAddrs.length / activeAddrs.length : 0; + const biggestCluster = clusters.length ? clusters[0].length : 0; + let healthBand, healthColor, healthMsg; + if (reusedAddrs.length === 0 && clusters.length === 0) { + healthBand = "ALTA"; healthColor = C.green; + healthMsg = "No se detecta reutilización de direcciones ni vinculación evidente entre tus monedas."; + } else if (reuseRatio < 0.25 && biggestCluster <= 3) { + healthBand = "MEDIA"; healthColor = C.amber; + healthMsg = "Hay algo de reutilización o vinculación, pero limitada. Revisa los clusters para entender qué monedas están conectadas."; + } else { + healthBand = "BAJA"; healthColor = C.red; + healthMsg = "Tus monedas presentan vinculación significativa o reutilización frecuente. Un observador puede agrupar buena parte de tu actividad."; + } + + return { totalTxs, activeCount: activeAddrs.length, reusedAddrs, clusters, linkReasons, history, + health: { band: healthBand, color: healthColor, msg: healthMsg } }; + } + function analyzeTx(tx) { const checks = []; const weights = { @@ -2255,11 +2334,15 @@ )} - {!check.informational&&!check.pass&&check.penalty>0&&−{check.penalty}} {expanded===check.id&&(
{check.detail}
+ {!check.informational&&!check.pass&&check.penalty>0&&( +
+ Peso en la banda: {check.penalty>=40?"alto":check.penalty>=20?"medio":"bajo"} +
+ )} {check.didactic&&(
))}
- +
+ + +
+ {walletScanning&&walletProgress&&( +
{walletProgress}
+ )} ):(
@@ -3865,6 +4018,146 @@ )} + {walletReport&&!walletReport.error&&( + + {/* Cabecera del informe con exportación */} +
+
INFORME DE WALLET
+
+ + +
+
+ + {/* Bloque 1: Salud general */} +
+
+ {walletReport.health.band} +
+
+
+ {walletReport.health.band==="ALTA"?"Wallet bien compartimentado":walletReport.health.band==="MEDIA"?"Vinculación parcial detectada":"Vinculación significativa"} +
+
{walletReport.health.msg}
+
+ {[ + {label:"Txs",v:walletReport.totalTxs}, + {label:"Activas",v:walletReport.activeCount}, + {label:"Reutilizadas",v:walletReport.reusedAddrs.length,warn:walletReport.reusedAddrs.length>0}, + {label:"Clusters",v:walletReport.clusters.length,warn:walletReport.clusters.length>0}, + ].map(({label,v,warn})=>( +
+
{label}
+
{v}
+
+ ))} +
+
+
+ + {/* Bloque 2: Clusters de vinculación */} + {walletReport.clusters.length>0&&( +
+
CLUSTERS DE VINCULACIÓN
+
+ {walletReport.clusters.map((cluster,i)=>{ + const reasons=walletReport.linkReasons.filter(r=>r.addrs.some(a=>cluster.includes(a))); + const barW=Math.min(100,Math.round((cluster.length/Math.max(...walletReport.clusters.map(c=>c.length)))*100)); + return ( +
+
+
Cluster {i+1}
+
+
+
+
{cluster.length} dirs.
+
+ {reasons.length>0&&( +
+ Vinculadas porque {reasons[0].reason} + {reasons.length>1&& (+{reasons.length-1} más)} +
+ )} +
+ {cluster.slice(0,4).map((a,j)=>( +
+ {labels?.get(a)&&🏷 {labels.get(a)}} + {a.slice(0,14)}…{a.slice(-8)} +
+ ))} + {cluster.length>4&&
+{cluster.length-4} más
} +
+
+ ); + })} +
+
+ )} + {walletReport.clusters.length===0&&( +
+ ✓ No se detectan clusters de vinculación entre tus monedas. +
+ )} + + {/* Bloque 3: Historial */} + {walletReport.history.length>0&&( +
+
HISTORIAL — {walletReport.history.length} TRANSACCIONES
+
+ {walletReport.history.map((t,i)=>{ + const bandColor=t.band==="ALTA"?C.green:t.band==="MEDIA"?C.amber:t.band==="BAJA"?C.red:C.t2; + return ( +
{setQuery(t.txid);setShowWalletPanel(false);setTimeout(()=>analyze(t.txid),50);}} + style={{display:"flex",alignItems:"center",gap:10,padding:"7px 10px",background:C.bgCard,borderRadius:5,cursor:"pointer",border:`1px solid ${C.border}`,transition:"border-color 0.15s"}} + onMouseEnter={e=>e.currentTarget.style.borderColor=bandColor} + onMouseLeave={e=>e.currentTarget.style.borderColor=C.border}> +
+
+ {t.time?new Date(t.time*1000).toLocaleDateString("es-ES",{day:"2-digit",month:"2-digit",year:"2-digit"}):"pendiente"} +
+
+ {labels?.get(t.txid)&&🏷} + {t.txid.slice(0,20)}… +
+
{t.band||"—"}
+
+ ); + })} +
+
+ )} + + )} + {walletReport?.error&&( + +
Error al analizar el wallet: {walletReport.error}
+
+ )} + {showLabelModal&&(
diff --git a/system-metrics.js b/system-metrics.js index 1733f11..fd0ea13 100644 --- a/system-metrics.js +++ b/system-metrics.js @@ -1,157 +1,350 @@ +#!/usr/bin/env node /** - * txoko-metrics — Backend de métricas del nodo Bitcoin + * Txoko Node - System Metrics Server (v2 — ligero) + * Expone métricas del sistema via HTTP en localhost. * - * Servidor HTTP Node.js que escucha en 127.0.0.1:4082. - * Sirve métricas del sistema y estado de Bitcoin Core al dashboard. + * 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 * - * CONFIGURACIÓN: - * Editar las variables de la sección "CONFIGURACIÓN LOCAL" con - * los datos de tu nodo antes de arrancar. - * - * ARRANCAR: + * Setup: + * npm install express * node system-metrics.js * - * O como servicio systemd: - * sudo systemctl start txoko-metrics + * Acceso: http://127.0.0.1:4082 */ -// ── CONFIGURACIÓN LOCAL ────────────────────────────────────────────── -// Copia este archivo como system-metrics.local.js y edita estos valores. -// El archivo .local.js está en .gitignore — nunca se sube al repo. +const express = require("express"); +const { exec } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const http = require("http"); -const RPC_HOST = '127.0.0.1'; -const RPC_PORT = 8332; -const RPC_USER = 'TU_RPC_USER'; // Ver bitcoin.conf → rpcuser -const RPC_PASSWORD = 'TU_RPC_PASSWORD'; // Ver bitcoin.conf → rpcpassword -// Alternativa: autenticación por cookie (más seguro) -// const RPC_COOKIE = '/data/bitcoin/.cookie'; +// ── 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 METRICS_PORT = 4082; -const METRICS_HOST = '127.0.0.1'; // Solo localhost — no exponer al exterior +const PORT = 4082; +const HOST = "127.0.0.1"; +// ── FIN CONFIGURACIÓN ─────────────────────────────────────────────────────── -// ── FIN CONFIGURACIÓN ──────────────────────────────────────────────── +const app = express(); -const http = require('http'); -const { execSync } = require('child_process'); -const fs = require('fs'); -const os = require('os'); +app.use((req, res, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET"); + next(); +}); -// Cliente RPC Bitcoin Core -async function rpcCall(method, params = []) { - const body = JSON.stringify({ jsonrpc: '1.0', id: 'txoko', method, params }); - const auth = Buffer.from(`${RPC_USER}:${RPC_PASSWORD}`).toString('base64'); +// ── 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(300); + 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 arg1 = (parts[11] || "").split("/").pop(); + const interp = /^(node|python\d?|bash|sh|perl)$/.test(exe); + const command = (interp && arg1 ? `${exe} (${arg1})` : exe).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}` } + host: RPC_HOST, port: RPC_PORT, method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Basic ${auth}` }, + timeout: 8000, }, res => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { + let data = ""; + res.on("data", c => data += c); + res.on("end", () => { try { const parsed = JSON.parse(data); - if (parsed.error) reject(parsed.error); + 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("error", reject); + req.on("timeout", () => { req.destroy(); reject(new Error("RPC timeout")); }); req.write(body); req.end(); }); } -// Endpoints -const routes = { - - '/system/info': async () => { - const cpus = os.cpus(); - const totalMem = os.totalmem(); - const freeMem = os.freemem(); - const usedMem = totalMem - freeMem; - - // CPU usage (snapshot simple) - const cpuUsage = cpus.reduce((sum, cpu) => { - const total = Object.values(cpu.times).reduce((a, b) => a + b, 0); - const idle = cpu.times.idle; - return sum + ((total - idle) / total) * 100; - }, 0) / cpus.length; - - return { - cpu: { usage: cpuUsage.toFixed(1), cores: cpus.length, model: cpus[0]?.model }, - memory: { - total: totalMem, free: freeMem, used: usedMem, - usedPercent: ((usedMem / totalMem) * 100).toFixed(1) - }, - uptime: os.uptime(), - platform: os.platform(), - hostname: os.hostname() - }; - }, - - '/system/bitcoin': async () => { - const [info, netinfo, mempoolinfo] = await Promise.all([ - rpcCall('getblockchaininfo'), - rpcCall('getnetworkinfo'), - rpcCall('getmempoolinfo') - ]); - return { blockchain: info, network: netinfo, mempool: mempoolinfo }; - }, - - '/system/logs/bitcoin': async () => { - try { - const lines = execSync('journalctl -u bitcoind -n 80 --no-pager -o short-iso 2>/dev/null || tail -80 /data/bitcoin/debug.log 2>/dev/null || echo ""') - .toString().trim().split('\n').filter(Boolean); - return { logs: lines.map(line => ({ - line, - level: line.includes('error') || line.includes('ERROR') ? 'error' - : line.includes('warn') || line.includes('WARN') ? 'warn' - : 'info' - }))}; - } catch { return { logs: [] }; } - }, - - '/system/logs/fulcrum': async () => { - try { - const lines = execSync('journalctl -u fulcrum -n 80 --no-pager -o short-iso 2>/dev/null || echo ""') - .toString().trim().split('\n').filter(Boolean); - return { logs: lines.map(line => ({ - line, - level: line.includes('Error') || line.includes('error') ? 'error' - : line.includes('Warn') || line.includes('warn') ? 'warn' - : 'info' - }))}; - } catch { return { logs: [] }; } - }, - - '/system/health': async () => ({ status: 'ok', ts: Date.now() }) -}; - -// Servidor HTTP -const server = http.createServer(async (req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Content-Type', 'application/json'); - - // Extraer ruta sin query string - const path = req.url.split('?')[0]; - const handler = routes[path]; - - if (!handler) { - res.writeHead(404); - return res.end(JSON.stringify({ error: 'Not found' })); - } - +async function getBitcoinInfo() { try { - const data = await handler(); - res.writeHead(200); - res.end(JSON.stringify(data)); - } catch (err) { - res.writeHead(500); - res.end(JSON.stringify({ error: err.message || 'Internal error' })); - } + 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 }; + }); +} + +// ── Routes (caché por ruta: el trabajo se hace 1 vez por ventana) ────────── +app.get("/system/info", async (req, res) => { + try { + const data = await 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()}`, + }; + }); + res.json(data); + } catch (e) { res.status(500).json({ error: e.message }); } }); -server.listen(METRICS_PORT, METRICS_HOST, () => { - console.log(`txoko-metrics escuchando en ${METRICS_HOST}:${METRICS_PORT}`); +app.get("/system/bitcoin", async (req, res) => { + try { + const info = await cached("bitcoin", 5000, getBitcoinInfo); + if (!info) return res.status(503).json({ error: "No se pudo conectar a Bitcoin Core" }); + res.json(info); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +app.get("/system/logs/bitcoin", async (req, res) => { + try { + const lines = parseInt(req.query.lines) || 50; + const logs = await cached(`logs-btc-${lines}`, 10000, () => getLogs("bitcoind", lines)); + res.json({ logs }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +app.get("/system/logs/fulcrum", async (req, res) => { + try { + const lines = parseInt(req.query.lines) || 50; + const logs = await cached(`logs-ful-${lines}`, 10000, () => getLogs("fulcrum", lines)); + res.json({ logs }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +app.get("/system/health", (req, res) => { + res.json({ status: "ok", timestamp: Date.now() }); +}); + +// ── Start ─────────────────────────────────────────────────────────────────── +app.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 + `); });