diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5af7d92..d403883 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,38 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
- **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores
+---
+## [1.11.0] — 2026-07-27
+### Añadido
+- **Las librerías del frontend se sirven desde tu propio nodo.** React, ReactDOM
+ y Babel se cargaban desde unpkg.com, y las fuentes IBM Plex desde
+ fonts.googleapis.com. Ninguno de los dos veía qué transacciones analizabas —
+ pero ambos recibían tu IP pública, la hora y el referer en cada apertura del
+ dashboard: sabían que usabas Txoko, cuándo y desde dónde. Ese metadato es
+ justo lo que esta herramienta enseña a proteger. Ahora no sale ni una
+ petición fuera de tu red, verificable con
+ `grep -E '(src|href)="https?://' dashboard.html` (no devuelve nada).
+ - Consecuencia práctica: **el dashboard funciona sin conexión a internet**.
+ - Las librerías se descargan una vez durante la instalación y se **verifican
+ por hash** (SHA-256 publicados en SETUP.md).
+ - Versiones fijadas (`react@18.3.1`, `@babel/standalone@7.23.10`) en vez de
+ rangos: antes el CDN decidía qué versión te entregaba y podía cambiar sin
+ aviso.
+ - `instalar-fuentes.sh` automatiza la descarga de IBM Plex (licencia OFL) y
+ genera el CSS. Comprueba cada archivo y aborta si algo falla.
+- **Caché de respuestas del nodo con TTL y coalescencia** en `useApi`. Las
+ pestañas se montan y desmontan al navegar, así que ir a otra pestaña y volver
+ repetía todas las peticiones. Ahora:
+ - Las **transacciones confirmadas se cachean toda la sesión** — son
+ inmutables, no hay motivo para volver a pedirlas. Analizador, rastro de
+ procedencia y peritaje dejan de pedir las mismas por separado.
+ - Historial de direcciones 60 s, bloques 60 s, mempool y comisiones 20 s,
+ estado del sistema 2 s (para que el monitor siga mostrando datos vivos).
+ - **Coalescencia:** dos componentes que piden lo mismo a la vez generan una
+ sola petición al nodo, no dos. Mismo patrón que ya usaba
+ `system-metrics.js` en el servidor.
+ - Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
+
---
## [1.10.1] — 2026-07-27
### Añadido
diff --git a/README.md b/README.md
index 5ccb18a..94a94b8 100644
--- a/README.md
+++ b/README.md
@@ -39,9 +39,11 @@ El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo:
grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html
```
-Verás que todas las llamadas van a rutas relativas (`/api/`, `/system/`) que apuntan a tu propio nodo vía nginx. No hay ninguna llamada a dominios externos en el código de análisis.
+El comando anterior no debe devolver **ninguna** URL externa. Todas las llamadas van a rutas relativas (`/api/`, `/system/`, `/vendor/`) que apuntan a tu propio nodo vía nginx.
-Las únicas dependencias externas son las librerías del frontend (React, Babel) que se cargan desde CDN al abrir la página. Estas no reciben ningún dato de tus transacciones — solo sirven el código de la interfaz.
+Esto incluye las librerías del frontend (React y Babel): también se sirven desde tu nodo, no desde un CDN. Hasta la versión 1.10.1 se cargaban desde unpkg.com, y aunque un CDN nunca vio qué transacciones analizabas, sí recibía tu IP pública cada vez que abrías el dashboard — es decir, sabía que usabas Txoko, cuándo y desde dónde. Ese metadato es justo lo que esta herramienta enseña a proteger, así que se eliminó.
+
+Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo necesita tu nodo. Las librerías se descargan una vez durante la instalación y se verifican por hash — ver [SETUP.md](SETUP.md).
---
diff --git a/SETUP.md b/SETUP.md
index 465da09..b25837a 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -159,6 +159,85 @@ Cuatro comandos después de la descarga. Siempre los mismos.
---
+## Librerías del frontend (obligatorio)
+
+El dashboard usa React y Babel. **Se sirven desde tu propio nodo, no desde un
+CDN.** El motivo es de privacidad, no de comodidad: un CDN externo no ve qué
+transacciones analizas, pero sí ve que usas Txoko, cuándo y desde qué IP
+pública — exactamente la clase de metadato que esta herramienta enseña a
+proteger. Sirviéndolas en local, *"ninguna consulta sale de tu red"* pasa a ser
+literal, y el dashboard funciona sin conexión a internet.
+
+Estas librerías **no están en el repo** (son de terceros y pesan ~3 MB). Se
+descargan una vez, en el nodo:
+
+```bash
+sudo mkdir -p /usr/share/nginx/html/vendor
+cd /usr/share/nginx/html/vendor
+sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
+sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
+sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
+```
+
+### Verifica lo que has descargado
+
+No te fíes: comprueba que los archivos son los que deben ser.
+
+```bash
+sha256sum *.js
+```
+
+Debe dar exactamente esto:
+
+```
+85ba0c7207cf1b1850e40372f26a7e69a481457a29649b7ca19bbfce8604f30c babel.min.js
+35f4f974f4b2bcd44da73963347f8952e341f83909e4498227d4e26b98f66f0d react-dom.production.min.js
+d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
+```
+
+Si algún hash no coincide, **no uses esos archivos**: significa que has
+recibido algo distinto a lo esperado.
+
+Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). Usar un rango
+como `react@18` dejaría que el servidor decidiera qué versión te entrega, y
+cambiaría con el tiempo sin que te enteres.
+
+### Dónde deben quedar los archivos
+
+El dashboard las busca en `vendor/` con **ruta relativa**, es decir, en un
+subdirectorio junto al propio `dashboard.html`. Así funciona tanto si sirves el
+dashboard en la raíz como bajo un prefijo, sin tocar nginx.
+
+Con la configuración típica de Mempool self-hosted:
+
+```nginx
+location /dashboard {
+ alias /usr/share/nginx/html/;
+}
+```
+
+…el `dashboard.html` vive en `/usr/share/nginx/html/` y las librerías deben ir
+en `/usr/share/nginx/html/vendor/` — que es justo donde las deja el comando de
+arriba.
+
+**Importante:** abre el dashboard **con la barra final** (`.../dashboard/`, no
+`.../dashboard`). Sin ella, el navegador resuelve las rutas relativas un nivel
+por encima y no encuentra las librerías.
+
+Para comprobar que nginx las sirve bien, lo que importa no es solo el código
+200 sino el tipo de contenido:
+
+```bash
+curl -sk -o /dev/null -w '%{http_code} %{content_type}\n' \
+ https://localhost:4081/dashboard/vendor/react.production.min.js
+```
+
+Debe responder `200 application/javascript`. Si devuelve `200 text/html`,
+nginx está entregando otra cosa (por ejemplo el index de Mempool) y las rutas
+no son las correctas.
+
+---
+
## HTTPS para watch-only (opcional)
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
diff --git a/dashboard.html b/dashboard.html
index 40b0c9c..b380945 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -4,9 +4,19 @@
Txoko Node Dashboard
-
-
-
+
+
+
+
+
-
-
+
+
@@ -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" && }), 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 };
}
diff --git a/instalar-fuentes.sh b/instalar-fuentes.sh
new file mode 100755
index 0000000..eab2f5b
--- /dev/null
+++ b/instalar-fuentes.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────────────────────
+# Txoko — instalar IBM Plex en local (elimina la dependencia de Google Fonts)
+#
+# Descarga las fuentes IBM Plex (licencia OFL, libre) y genera el CSS que las
+# declara. Tras ejecutarlo, el dashboard deja de contactar con
+# fonts.googleapis.com y fonts.gstatic.com.
+#
+# Por qué importa: Google no ve qué transacciones analizas, pero sí recibe tu
+# IP, la hora y la página desde la que se pide la fuente, cada vez que abres el
+# dashboard. Para una herramienta de privacidad, sobra.
+#
+# Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen
+# del que ya se descargan React y Babel durante la instalación.
+#
+# Ejecutar EN EL NODO:
+# chmod +x instalar-fuentes.sh && sudo ./instalar-fuentes.sh
+# ─────────────────────────────────────────────────────────────────────────────
+set -uo pipefail
+
+DEST="${1:-/usr/share/nginx/html/vendor/fonts}"
+MONO="https://unpkg.com/@ibm/plex-mono@1.1.0/fonts/complete/woff2"
+SANS="https://unpkg.com/@ibm/plex-sans@1.1.0/fonts/complete/woff2"
+
+echo "→ Instalando fuentes en: $DEST"
+mkdir -p "$DEST"
+cd "$DEST" || { echo "✗ No se pudo entrar en $DEST"; exit 1; }
+
+# Pesos que usa el dashboard: Mono 400/600/700, Sans 400/500/600.
+# Formato woff2 (el más comprimido, soportado por todo navegador actual).
+URLS="
+$MONO/IBMPlexMono-Regular.woff2
+$MONO/IBMPlexMono-SemiBold.woff2
+$MONO/IBMPlexMono-Bold.woff2
+$SANS/IBMPlexSans-Regular.woff2
+$SANS/IBMPlexSans-Medium.woff2
+$SANS/IBMPlexSans-SemiBold.woff2
+"
+
+fallos=0
+for url in $URLS; do
+ archivo=$(basename "$url")
+ printf " %-32s" "$archivo"
+ # -w escribe el código HTTP para poder diagnosticar si algo va mal
+ codigo=$(curl -sSL -o "$archivo" -w "%{http_code}" "$url" 2>/dev/null)
+ tam=$(stat -c%s "$archivo" 2>/dev/null || echo 0)
+ if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then
+ echo "ok ($((tam/1024)) KB)"
+ else
+ echo "FALLÓ (HTTP $codigo, $tam bytes)"
+ fallos=$((fallos+1))
+ fi
+done
+
+if [ "$fallos" -gt 0 ]; then
+ echo
+ echo "✗ $fallos archivo(s) no se descargaron correctamente."
+ echo " No despliegues el dashboard nuevo todavía — avísame con esta salida."
+ exit 1
+fi
+
+# ── CSS que declara las fuentes ──────────────────────────────────────────────
+# font-display:swap → el texto se ve desde el primer momento con la fuente de
+# respaldo y se sustituye al cargar la definitiva. Sin página en blanco.
+cat > ibm-plex.css <<'CSS'
+/* IBM Plex — servido desde tu propio nodo. Licencia OFL (IBM).
+ Sustituye a fonts.googleapis.com: ninguna petición sale de tu red. */
+@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexMono-Regular.woff2') format('woff2')}
+@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexMono-SemiBold.woff2') format('woff2')}
+@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;src:url('IBMPlexMono-Bold.woff2') format('woff2')}
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexSans-Regular.woff2') format('woff2')}
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url('IBMPlexSans-Medium.woff2') format('woff2')}
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexSans-SemiBold.woff2') format('woff2')}
+CSS
+
+echo
+echo "✓ Fuentes instaladas y CSS generado."
+echo " Total: $(du -sh . | cut -f1)"
+echo
+echo " Comprueba que nginx las sirve (debe dar 200):"
+echo " curl -sk -o /dev/null -w '%{http_code}\\n' https://localhost:4081/vendor/fonts/ibm-plex.css"
+echo
+echo " Si da 200, ya puedes desplegar el dashboard.html nuevo."