diff --git a/dashboard.html b/dashboard.html
index 2445af0..9446ecf 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -4998,42 +4998,63 @@
const [amountInput, setAmountInput] = useState("");
const [declaracion, setDeclaracion] = useState("");
const [maxHops, setMaxHops] = useState(8);
- const [scanning, setScanning] = useState(false);
+ const [busy, setBusy] = useState(false); // hay una petición al nodo en curso (iniciar o avanzar un salto)
const [progress, setProgress] = useState("");
const [error, setError] = useState(null);
- const [graph, setGraph] = useState(null);
const [report, setReport] = useState(null);
+ const [tick, setTick] = useState(0); // fuerza re-render al mutar traceRef.current
+ // El rastreo vive en un ref (no en estado) porque lo muta directamente
+ // advanceForensicHop entre clics del usuario — "tick" es lo que le dice
+ // a React que vuelva a leerlo. Así el estado (nodos, aristas, frontier)
+ // sobrevive entre saltos sin tener que reconstruirlo cada vez.
+ const traceRef = useRef(null);
- const iniciar = async (hops) => {
+ const avanzarSalto = async (trace) => {
+ setBusy(true); setError(null);
+ try {
+ await advanceForensicHop(trace, { maxHops, onProgress: setProgress });
+ setTick(t => t+1);
+ } catch(e) {
+ setError(e.message);
+ }
+ setBusy(false); setProgress("");
+ };
+
+ const iniciar = async () => {
const txid = txidInput.trim();
if (!/^[0-9a-f]{64}$/i.test(txid)) { setError("Introduce un txid de origen válido (64 caracteres hex)."); return; }
const vout = parseInt(voutInput, 10);
if (!Number.isInteger(vout) || vout < 0) { setError("El vout debe ser un número entero ≥ 0."); return; }
if (!base) { setError("Necesitas conectar tu nodo (CONFIG) para rastrear — el peritaje consulta tu Mempool self-hosted, no funciona en modo demo."); return; }
- setError(null); setScanning(true); setGraph(null); setReport(null);
- const effectiveHops = hops || maxHops;
+ setError(null); setBusy(true); setReport(null);
try {
const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null;
- const g = await buildForensicGraph({
- get, originTxid: txid, originVout: vout, amountStolen,
- maxHops: effectiveHops,
- onProgress: (msg) => setProgress(msg),
- });
- setGraph(g);
- setReport(buildForensicReport(g, declaracion));
+ const trace = await initForensicTrace({ get, originTxid: txid, originVout: vout, amountStolen });
+ traceRef.current = trace;
+ setTick(t => t+1);
+ await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo
} catch(e) {
setError(e.message);
+ setBusy(false);
}
- setScanning(false); setProgress("");
};
- const seguirMasSaltos = () => {
- const next = maxHops + 4;
- setMaxHops(next);
- iniciar(next);
+ const seguir = () => {
+ if (traceRef.current && !traceRef.current.done && !busy) avanzarSalto(traceRef.current);
};
- const limpiar = () => { setGraph(null); setReport(null); setError(null); };
+ const limpiar = () => {
+ traceRef.current = null; setTick(t=>t+1);
+ setReport(null); setError(null); setBusy(false); setProgress("");
+ };
+
+ const generarInforme = () => {
+ if (!traceRef.current) return;
+ const g = finalizeForensicGraph(traceRef.current);
+ setReport(buildForensicReport(g, declaracion));
+ };
+
+ const trace = traceRef.current; // solo lectura, para el render — tick fuerza refrescarlo
const exportJSON = () => {
const exportData = {
@@ -5095,44 +5116,91 @@
- Rastrea fondos robados o perdidos hacia adelante, salto a salto, desde la transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar). Todo el rastreo corre sobre tu propio nodo — nada sale de tu red.
+ Rastrea fondos robados o perdidos hacia adelante, salto a salto, desde la transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar). A demanda: tú decides cuándo se explora el siguiente salto — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Todo sobre tu propio nodo — nada sale de tu red.
- setTxidInput(e.target.value)}
+ setTxidInput(e.target.value)} disabled={!!trace}
placeholder="txid de origen (64 caracteres hex)"
- style={{flex:3,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none"}}
+ style={{flex:3,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",opacity:trace?0.5:1}}
/>
- setVoutInput(e.target.value)} type="number" min="0"
+ setVoutInput(e.target.value)} type="number" min="0" disabled={!!trace}
placeholder="vout" title="Índice del output robado dentro de esa transacción"
- style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none"}}
+ style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",opacity:trace?0.5:1}}
/>
+
- {report.truncatedBranches.length} rama(s) cortada(s) por el límite de saltos ({maxHops}) — no es un punto de parada natural, se pueden ampliar.
+
+ {report.truncatedBranches.length} rama(s) cortada(s) por el tope de saltos ({maxHops}) — no es un punto de parada natural. Ese tope solo es una red de seguridad aparte del control manual; para explorarlas, sube el tope arriba y pulsa "Limpiar" para repetir el rastreo desde el principio con el tope nuevo (una rama ya cortada no se puede reanudar por separado).
-
- {scanning?"···":`Seguir más saltos (+4, hasta ${maxHops+4})`}
-