feat: informe de wallet (vinculación CIOH, reutilización, historial); fix: monitor v2 con caché TTL (resuelve CPU alta); mejora presentación de checks
This commit is contained in:
@@ -22,3 +22,4 @@ logs/
|
|||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
txoko-roadmap.md
|
txoko-roadmap.md
|
||||||
|
TRASPASO.md
|
||||||
|
|||||||
+295
-2
@@ -1285,6 +1285,85 @@
|
|||||||
return result;
|
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) {
|
function analyzeTx(tx) {
|
||||||
const checks = [];
|
const checks = [];
|
||||||
const weights = {
|
const weights = {
|
||||||
@@ -2255,11 +2334,15 @@
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<CertaintyBadge level={check.certainty}/>
|
<CertaintyBadge level={check.certainty}/>
|
||||||
{!check.informational&&!check.pass&&check.penalty>0&&<span style={{fontSize:"0.62rem",color:C.red,fontFamily:"monospace",marginLeft:4}}>−{check.penalty}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{expanded===check.id&&(
|
{expanded===check.id&&(
|
||||||
<div style={{padding:"0 14px 12px 14px",borderTop:`1px solid ${C.border}`}}>
|
<div style={{padding:"0 14px 12px 14px",borderTop:`1px solid ${C.border}`}}>
|
||||||
<div style={{fontSize:"0.7rem",color:C.t2,lineHeight:1.6,paddingTop:10}}>{check.detail}</div>
|
<div style={{fontSize:"0.7rem",color:C.t2,lineHeight:1.6,paddingTop:10}}>{check.detail}</div>
|
||||||
|
{!check.informational&&!check.pass&&check.penalty>0&&(
|
||||||
|
<div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
|
||||||
|
Peso en la banda: {check.penalty>=40?"alto":check.penalty>=20?"medio":"bajo"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{check.didactic&&(
|
{check.didactic&&(
|
||||||
<div style={{marginTop:8}}>
|
<div style={{marginTop:8}}>
|
||||||
<button onClick={e=>{e.stopPropagation();setShowDidactic(showDidactic===check.id?null:check.id);}}
|
<button onClick={e=>{e.stopPropagation();setShowDidactic(showDidactic===check.id?null:check.id);}}
|
||||||
@@ -3761,6 +3844,66 @@
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Análisis de wallet completo ──────────────────────────────────────
|
||||||
|
const [walletReport,setWalletReport]=useState(null);
|
||||||
|
const [walletScanning,setWalletScanning]=useState(false);
|
||||||
|
const [walletProgress,setWalletProgress]=useState("");
|
||||||
|
|
||||||
|
// Trae datos del nodo por lotes, con pausa, para no saturar Fulcrum
|
||||||
|
const scanWallet = async () => {
|
||||||
|
if (!walletAddrs || !base) return;
|
||||||
|
setWalletScanning(true); setWalletReport(null);
|
||||||
|
try {
|
||||||
|
const all = [...walletAddrs.receive.map(a=>({addr:a,branch:"recepción"})),
|
||||||
|
...walletAddrs.change.map(a=>({addr:a,branch:"cambio"}))];
|
||||||
|
const BATCH = 5; // direcciones por lote
|
||||||
|
const PAUSE = 120; // ms entre lotes
|
||||||
|
const active = []; // direcciones con actividad
|
||||||
|
|
||||||
|
// Fase 1: estado ligero de cada dirección
|
||||||
|
for (let i = 0; i < all.length; i += BATCH) {
|
||||||
|
const slice = all.slice(i, i + BATCH);
|
||||||
|
setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`);
|
||||||
|
const infos = await Promise.all(slice.map(s =>
|
||||||
|
get(`/api/address/${s.addr}`, null).catch(()=>null)
|
||||||
|
));
|
||||||
|
infos.forEach((info, j) => {
|
||||||
|
const txc = info?.chain_stats?.tx_count || 0;
|
||||||
|
if (txc > 0) active.push({ ...slice[j], info });
|
||||||
|
});
|
||||||
|
if (i + BATCH < all.length) await new Promise(r => setTimeout(r, PAUSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fase 2: txs de las direcciones con actividad, con paginación
|
||||||
|
// (la API devuelve ~25 txs por página; seguimos con /txs/chain/{last})
|
||||||
|
const txMap = new Map();
|
||||||
|
const myAddrSet = new Set(all.map(a => a.addr));
|
||||||
|
for (let i = 0; i < active.length; i += BATCH) {
|
||||||
|
const slice = active.slice(i, i + BATCH);
|
||||||
|
setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`);
|
||||||
|
await Promise.all(slice.map(async s => {
|
||||||
|
let page = await get(`/api/address/${s.addr}/txs`, []).catch(()=>[]);
|
||||||
|
let guard = 0;
|
||||||
|
while (Array.isArray(page) && page.length > 0 && guard < 8) {
|
||||||
|
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
|
||||||
|
if (page.length < 25) break; // última página
|
||||||
|
const last = page[page.length-1].txid;
|
||||||
|
page = await get(`/api/address/${s.addr}/txs/chain/${last}`, []).catch(()=>[]);
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
setWalletProgress("Analizando vinculación…");
|
||||||
|
const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
|
||||||
|
setWalletReport(report);
|
||||||
|
} catch(e) {
|
||||||
|
setWalletReport({ error: e.message });
|
||||||
|
}
|
||||||
|
setWalletScanning(false); setWalletProgress("");
|
||||||
|
};
|
||||||
|
|
||||||
const loadLabels = (file) => {
|
const loadLabels = (file) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
@@ -3836,10 +3979,20 @@
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={clearWallet} style={{padding:"5px 12px",background:"none",border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
|
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||||||
|
<button onClick={scanWallet} disabled={walletScanning||!base}
|
||||||
|
title={!base?"Necesitas conectar tu nodo (CONFIG) para analizar el wallet":""}
|
||||||
|
style={{padding:"6px 14px",background:C.purpleMuted,border:`1px solid ${C.purple}60`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",fontWeight:700}}>
|
||||||
|
{walletScanning?"···":"⊙ Analizar wallet"}
|
||||||
|
</button>
|
||||||
|
<button onClick={clearWallet} style={{padding:"6px 14px",background:"none",border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
|
||||||
Borrar wallet
|
Borrar wallet
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{walletScanning&&walletProgress&&(
|
||||||
|
<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{walletProgress}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
):(
|
):(
|
||||||
<div style={{display:"flex",gap:8}}>
|
<div style={{display:"flex",gap:8}}>
|
||||||
<input value={xpubInput} onChange={e=>setXpubInput(e.target.value)}
|
<input value={xpubInput} onChange={e=>setXpubInput(e.target.value)}
|
||||||
@@ -3865,6 +4018,146 @@
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{walletReport&&!walletReport.error&&(
|
||||||
|
<Card glow={C.purple}>
|
||||||
|
{/* Cabecera del informe con exportación */}
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
|
||||||
|
<div style={{fontSize:"0.65rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>INFORME DE WALLET</div>
|
||||||
|
<div style={{display:"flex",gap:6}}>
|
||||||
|
<button onClick={()=>{
|
||||||
|
const {health,...rest}=walletReport;
|
||||||
|
const exportData={...rest,health:{band:health.band,msg:health.msg}};
|
||||||
|
const blob=new Blob([JSON.stringify(exportData,null,2)],{type:"application/json"});
|
||||||
|
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.json";a.click();
|
||||||
|
}} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ JSON</button>
|
||||||
|
<button onClick={()=>{
|
||||||
|
const {health,totalTxs,activeCount,reusedAddrs,clusters,history}=walletReport;
|
||||||
|
const lines=[
|
||||||
|
`# Informe de Wallet — Txoko`,``,
|
||||||
|
`## Salud general: ${health.band}`,``,health.msg,``,
|
||||||
|
`- Transacciones analizadas: ${totalTxs}`,
|
||||||
|
`- Direcciones con actividad: ${activeCount}`,
|
||||||
|
`- Direcciones reutilizadas: ${reusedAddrs.length}`,
|
||||||
|
`- Clusters de vinculación: ${clusters.length}`,``,
|
||||||
|
`## Clusters de vinculación`,``,
|
||||||
|
...(clusters.length===0?[`No se detectan clusters.`]:clusters.flatMap((c,i)=>[
|
||||||
|
`### Cluster ${i+1} — ${c.length} direcciones vinculadas`,
|
||||||
|
...c.map(a=>`- ${a}`),``
|
||||||
|
])),``,
|
||||||
|
`## Historial (${history.length} transacciones)`,``,
|
||||||
|
...history.slice(0,20).map(t=>`- ${t.txid.slice(0,16)}… | ${t.band} | ${t.time?new Date(t.time*1000).toLocaleDateString("es-ES"):"sin confirmar"}`),
|
||||||
|
];
|
||||||
|
const blob=new Blob([lines.join("\n")],{type:"text/markdown"});
|
||||||
|
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.md";a.click();
|
||||||
|
}} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ MD</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bloque 1: Salud general */}
|
||||||
|
<div style={{display:"flex",alignItems:"center",gap:14,padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${walletReport.health.color}30`}}>
|
||||||
|
<div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||||||
|
<span style={{fontSize:"0.6rem",fontFamily:"monospace",fontWeight:700,color:walletReport.health.color}}>{walletReport.health.band}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{fontSize:"0.72rem",color:walletReport.health.color,fontWeight:700,marginBottom:4}}>
|
||||||
|
{walletReport.health.band==="ALTA"?"Wallet bien compartimentado":walletReport.health.band==="MEDIA"?"Vinculación parcial detectada":"Vinculación significativa"}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.5}}>{walletReport.health.msg}</div>
|
||||||
|
<div style={{display:"flex",gap:16,marginTop:8}}>
|
||||||
|
{[
|
||||||
|
{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})=>(
|
||||||
|
<div key={label}>
|
||||||
|
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
|
||||||
|
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:warn?C.amber:C.t1}}>{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bloque 2: Clusters de vinculación */}
|
||||||
|
{walletReport.clusters.length>0&&(
|
||||||
|
<div style={{marginBottom:14}}>
|
||||||
|
<div style={{fontSize:"0.6rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>CLUSTERS DE VINCULACIÓN</div>
|
||||||
|
<div style={{display:"flex",flexDirection:"column",gap:8}}>
|
||||||
|
{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 (
|
||||||
|
<div key={i} style={{padding:"10px 12px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}>
|
||||||
|
<div style={{display:"flex",alignItems:"center",gap:10,marginBottom:6}}>
|
||||||
|
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",fontWeight:600}}>Cluster {i+1}</div>
|
||||||
|
<div style={{flex:1,height:4,background:C.bg,borderRadius:2,overflow:"hidden"}}>
|
||||||
|
<div style={{width:`${barW}%`,height:"100%",background:C.amber,borderRadius:2}}/>
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{cluster.length} dirs.</div>
|
||||||
|
</div>
|
||||||
|
{reasons.length>0&&(
|
||||||
|
<div style={{fontSize:"0.6rem",color:C.t2,marginBottom:6,lineHeight:1.4}}>
|
||||||
|
Vinculadas porque {reasons[0].reason}
|
||||||
|
{reasons.length>1&&<span> (+{reasons.length-1} más)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{display:"flex",flexDirection:"column",gap:2}}>
|
||||||
|
{cluster.slice(0,4).map((a,j)=>(
|
||||||
|
<div key={j} style={{fontSize:"0.58rem",color:C.t1,fontFamily:"monospace"}}>
|
||||||
|
{labels?.get(a)&&<span style={{color:C.purple,marginRight:6}}>🏷 {labels.get(a)}</span>}
|
||||||
|
{a.slice(0,14)}…{a.slice(-8)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{cluster.length>4&&<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>+{cluster.length-4} más</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{walletReport.clusters.length===0&&(
|
||||||
|
<div style={{marginBottom:14,padding:"10px 12px",background:C.bgCard,border:`1px solid ${C.green}30`,borderRadius:6,fontSize:"0.65rem",color:C.green,fontFamily:"monospace"}}>
|
||||||
|
✓ No se detectan clusters de vinculación entre tus monedas.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bloque 3: Historial */}
|
||||||
|
{walletReport.history.length>0&&(
|
||||||
|
<div>
|
||||||
|
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>HISTORIAL — {walletReport.history.length} TRANSACCIONES</div>
|
||||||
|
<div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:320,overflowY:"auto"}}>
|
||||||
|
{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 (
|
||||||
|
<div key={i} onClick={()=>{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}>
|
||||||
|
<div style={{width:6,height:6,borderRadius:"50%",background:bandColor,flexShrink:0}}/>
|
||||||
|
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",minWidth:70}}>
|
||||||
|
{t.time?new Date(t.time*1000).toLocaleDateString("es-ES",{day:"2-digit",month:"2-digit",year:"2-digit"}):"pendiente"}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
|
||||||
|
{labels?.get(t.txid)&&<span style={{color:C.purple,marginRight:6}}>🏷</span>}
|
||||||
|
{t.txid.slice(0,20)}…
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:"0.58rem",color:bandColor,fontFamily:"monospace",fontWeight:600,flexShrink:0}}>{t.band||"—"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{walletReport?.error&&(
|
||||||
|
<Card glow={C.red}>
|
||||||
|
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace"}}>Error al analizar el wallet: {walletReport.error}</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{showLabelModal&&(
|
{showLabelModal&&(
|
||||||
<Card glow={C.purple}>
|
<Card glow={C.purple}>
|
||||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
|
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
|
||||||
|
|||||||
+315
-122
@@ -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.
|
* Cambios v2 respecto a v1:
|
||||||
* Sirve métricas del sistema y estado de Bitcoin Core al dashboard.
|
* - 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:
|
* Setup:
|
||||||
* Editar las variables de la sección "CONFIGURACIÓN LOCAL" con
|
* npm install express
|
||||||
* los datos de tu nodo antes de arrancar.
|
|
||||||
*
|
|
||||||
* ARRANCAR:
|
|
||||||
* node system-metrics.js
|
* node system-metrics.js
|
||||||
*
|
*
|
||||||
* O como servicio systemd:
|
* Acceso: http://127.0.0.1:4082
|
||||||
* sudo systemctl start txoko-metrics
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── CONFIGURACIÓN LOCAL ──────────────────────────────────────────────
|
const express = require("express");
|
||||||
// Copia este archivo como system-metrics.local.js y edita estos valores.
|
const { exec } = require("child_process");
|
||||||
// El archivo .local.js está en .gitignore — nunca se sube al repo.
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
const RPC_HOST = '127.0.0.1';
|
// ── CONFIGURACIÓN LOCAL ─────────────────────────────────────────────────────
|
||||||
|
const RPC_HOST = "127.0.0.1";
|
||||||
const RPC_PORT = 8332;
|
const RPC_PORT = 8332;
|
||||||
const RPC_USER = 'TU_RPC_USER'; // Ver bitcoin.conf → rpcuser
|
const RPC_USER = "TU_RPC_USER"; // bitcoin.conf → rpcuser
|
||||||
const RPC_PASSWORD = 'TU_RPC_PASSWORD'; // Ver bitcoin.conf → rpcpassword
|
const RPC_PASS = "TU_RPC_PASSWORD"; // bitcoin.conf → rpcpassword
|
||||||
// Alternativa: autenticación por cookie (más seguro)
|
|
||||||
// const RPC_COOKIE = '/data/bitcoin/.cookie';
|
|
||||||
|
|
||||||
const METRICS_PORT = 4082;
|
const PORT = 4082;
|
||||||
const METRICS_HOST = '127.0.0.1'; // Solo localhost — no exponer al exterior
|
const HOST = "127.0.0.1";
|
||||||
|
// ── FIN CONFIGURACIÓN ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── FIN CONFIGURACIÓN ────────────────────────────────────────────────
|
const app = express();
|
||||||
|
|
||||||
const http = require('http');
|
app.use((req, res, next) => {
|
||||||
const { execSync } = require('child_process');
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
const fs = require('fs');
|
res.setHeader("Access-Control-Allow-Methods", "GET");
|
||||||
const os = require('os');
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Cliente RPC Bitcoin Core
|
// ── Helpers asíncronos ──────────────────────────────────────────────────────
|
||||||
async function rpcCall(method, params = []) {
|
const readFile = (path) => {
|
||||||
const body = JSON.stringify({ jsonrpc: '1.0', id: 'txoko', method, params });
|
try { return fs.readFileSync(path, "utf8").trim(); } catch { return null; }
|
||||||
const auth = Buffer.from(`${RPC_USER}:${RPC_PASSWORD}`).toString('base64');
|
};
|
||||||
|
|
||||||
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = http.request({
|
const req = http.request({
|
||||||
host: RPC_HOST, port: RPC_PORT, method: 'POST',
|
host: RPC_HOST, port: RPC_PORT, method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` }
|
headers: { "Content-Type": "application/json", "Authorization": `Basic ${auth}` },
|
||||||
|
timeout: 8000,
|
||||||
}, res => {
|
}, res => {
|
||||||
let data = '';
|
let data = "";
|
||||||
res.on('data', chunk => data += chunk);
|
res.on("data", c => data += c);
|
||||||
res.on('end', () => {
|
res.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data);
|
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);
|
else resolve(parsed.result);
|
||||||
} catch (e) { reject(e); }
|
} 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.write(body);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Endpoints
|
async function getBitcoinInfo() {
|
||||||
const routes = {
|
try {
|
||||||
|
const [info, netinfo, uptime] = await Promise.all([
|
||||||
'/system/info': async () => {
|
rpcCall("getblockchaininfo"),
|
||||||
const cpus = os.cpus();
|
rpcCall("getnetworkinfo"),
|
||||||
const totalMem = os.totalmem();
|
rpcCall("uptime"),
|
||||||
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 };
|
if (!info || !netinfo) return null;
|
||||||
},
|
// connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no
|
||||||
|
let inbound = netinfo.connections_in;
|
||||||
'/system/logs/bitcoin': async () => {
|
let outbound = netinfo.connections_out;
|
||||||
try {
|
if (inbound === undefined || outbound === undefined) {
|
||||||
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 ""')
|
const peers = await rpcCall("getpeerinfo").catch(() => null);
|
||||||
.toString().trim().split('\n').filter(Boolean);
|
inbound = peers ? peers.filter(p => p.inbound).length : 0;
|
||||||
return { logs: lines.map(line => ({
|
outbound = peers ? peers.filter(p => !p.inbound).length : 0;
|
||||||
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' }));
|
|
||||||
}
|
}
|
||||||
|
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 {
|
try {
|
||||||
const data = await handler();
|
const data = await cached("info", 3000, async () => {
|
||||||
res.writeHead(200);
|
const [cpu, disk, processes] = await Promise.all([
|
||||||
res.end(JSON.stringify(data));
|
getCpuUsage(), getDisk(), getProcesses(),
|
||||||
} catch (err) {
|
]);
|
||||||
res.writeHead(500);
|
return {
|
||||||
res.end(JSON.stringify({ error: err.message || 'Internal error' }));
|
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, () => {
|
app.get("/system/bitcoin", async (req, res) => {
|
||||||
console.log(`txoko-metrics escuchando en ${METRICS_HOST}:${METRICS_PORT}`);
|
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
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user