feat: cero dependencias externas y caché de peticiones al nodo

Las librerías (React, Babel) venían de unpkg.com y las fuentes de Google.
Ninguno veía qué transacciones analizabas, pero ambos recibían tu IP y la hora
en cada apertura: sabían que usabas Txoko, cuándo y desde dónde — el metadato
que la propia herramienta enseña a proteger. Ahora se sirven desde el nodo,
con verificación por hash y versiones fijadas. El dashboard funciona sin
internet.

Además, caché con TTL y coalescencia en useApi: las transacciones confirmadas
son inmutables y se cachean toda la sesión, así que repetir un análisis ya
explorado no cuesta ninguna petición al nodo (medido).
This commit is contained in:
Aitor
2026-07-27 13:22:08 +02:00
parent 1b6904d2e3
commit 7c12f63f7a
5 changed files with 269 additions and 13 deletions
+71 -11
View File
@@ -4,9 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Txoko Node Dashboard</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.23.10/babel.min.js"></script>
<!-- Librerías servidas desde TU nodo, no desde un CDN. Un CDN externo no ve
qué transacciones analizas, pero sí que usas Txoko, cuándo y desde qué
IP — justo la clase de metadato que esta herramienta enseña a proteger.
Sirviéndolas en local, "ninguna consulta sale de tu red" es literal, y
el dashboard funciona sin conexión a internet.
Cómo obtenerlas y verificarlas: ver SETUP.md. -->
<!-- Rutas RELATIVAS a propósito: el dashboard puede servirse en la raíz o
bajo un prefijo (con Mempool self-hosted suele ser /dashboard/), y así
funciona en ambos casos sin tocar nginx. Requiere abrirlo con la barra
final: .../dashboard/ y no .../dashboard -->
<script src="vendor/react.production.min.js"></script>
<script src="vendor/react-dom.production.min.js"></script>
<script src="vendor/babel.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #05080d; color: #dde6f0; font-family: 'IBM Plex Sans', 'Helvetica Neue', sans-serif; }
@@ -17,8 +27,10 @@
button:disabled { opacity:0.4; cursor:not-allowed !important; }
input::placeholder { color: #4a6380; }
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Fuentes servidas desde TU nodo. Antes venían de fonts.googleapis.com,
lo que le daba a Google tu IP y la hora en cada apertura del dashboard.
Instalación: instalar-fuentes.sh (ver SETUP.md). -->
<link href="vendor/fonts/ibm-plex.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
@@ -2054,13 +2066,63 @@
}
}
// ── Caché de respuestas del nodo ──────────────────────────────────────
// Vive FUERA del hook a propósito. Las pestañas se montan y desmontan al
// navegar ({tab==="node" && <NodeOverview/>}), así que sin esto ir a
// BLOQUES y volver a NODO vuelve a pedirlo todo aunque hayan pasado dos
// segundos. Mismo patrón que ya usa system-metrics.js en el servidor
// (caché con TTL + coalescencia), aplicado ahora también en el navegador.
//
// La coalescencia importa tanto como el TTL: dos componentes que piden lo
// mismo a la vez generan UNA petición al nodo, no dos.
const apiCache = new Map(); // clave -> { at, data, ttl }
const apiInflight = new Map(); // clave -> Promise en vuelo
const API_CACHE_MAX = 400; // tope de entradas, para no crecer sin fin
// Cuánto vale una respuesta depende de lo que sea. Una transacción ya
// confirmada es INMUTABLE: no hay ningún motivo para volver a pedirla en
// toda la sesión. Es el caso más frecuente en el analizador, el rastro de
// procedencia y el peritaje, que hoy piden las mismas txs por separado.
function apiCacheTtl(path, data) {
if (path.startsWith("/system/")) return 2000; // estado vivo del nodo: casi sin caché
if (/^\/api\/tx\/[0-9a-f]{64}$/i.test(path)) {
return data && data.status && data.status.confirmed ? Infinity : 15000;
}
if (path.startsWith("/api/address/")) return 60000; // historial: cambia poco
if (path.startsWith("/api/block")) return 60000;
return 20000; // mempool, fees y demás
}
async function apiGetJson(baseUrl, path) {
const key = baseUrl + path;
const hit = apiCache.get(key);
if (hit && (hit.ttl === Infinity || Date.now() - hit.at < hit.ttl)) return hit.data;
const flying = apiInflight.get(key);
if (flying) return flying; // ya hay una petición idéntica en curso
const p = (async () => {
const res = await fetchWithTimeout(key);
if (!res.ok) throw new Error(`el nodo respondió HTTP ${res.status}`);
const data = await res.json();
const ttl = apiCacheTtl(path, data);
if (ttl > 0) {
// FIFO simple: al llenarse, cae la entrada más antigua.
if (apiCache.size >= API_CACHE_MAX) apiCache.delete(apiCache.keys().next().value);
apiCache.set(key, { at: Date.now(), data, ttl });
}
return data;
})();
apiInflight.set(key, p);
try { return await p; } finally { apiInflight.delete(key); }
}
function useApi(baseUrl) {
const get = useCallback(async (path, mockData) => {
if (!baseUrl) return mockData;
try {
const res = await fetchWithTimeout(`${baseUrl}${path}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
return await apiGetJson(baseUrl, path);
} catch { return mockData; }
}, [baseUrl]);
// Variante estricta: propaga el fallo en vez de disfrazarlo de dato
@@ -2071,9 +2133,7 @@
// pericial falsa. Quien use getStrict debe capturar y decir qué pasó.
const getStrict = useCallback(async (path) => {
if (!baseUrl) throw new Error("no hay nodo configurado");
const res = await fetchWithTimeout(`${baseUrl}${path}`);
if (!res.ok) throw new Error(`el nodo respondió HTTP ${res.status}`);
return res.json();
return apiGetJson(baseUrl, path); // misma caché; los errores sí suben
}, [baseUrl]);
return { get, getStrict };
}