chore: remove experimental frontends (web, android, playground, mobile)

Limpieza de los frontends de prueba (SPA React, app Kotlin, gateway playground,
binding gomobile) tras la fase de exploración. El bus (cmd/membershipd + pkg/*)
queda intacto y verde. Empezamos un frontend web nuevo desde cero, construido
de forma incremental. Todo lo borrado permanece en el historial git por si hay
que recuperar algo.
This commit is contained in:
agent
2026-06-07 17:38:07 +02:00
parent 926b8e96af
commit 9787c218ac
40 changed files with 0 additions and 5509 deletions
-594
View File
@@ -1,594 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>unibus playground</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--panel2: #1c2230;
--border: #2b333f;
--fg: #e6edf3;
--muted: #8b98a5;
--accent: #2f81f7;
--green: #3fb950;
--gold: #d29922;
--red: #f85149;
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
font-size: 14px;
line-height: 1.5;
}
header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 12px;
}
header h1 { margin: 0; font-size: 18px; letter-spacing: 0.5px; }
header .sub { color: var(--muted); font-size: 12px; }
.wrap {
display: grid;
grid-template-columns: 360px 1fr;
gap: 16px;
padding: 16px 20px;
max-width: 1200px;
}
.col { display: flex; flex-direction: column; gap: 14px; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.card h2 {
margin: 0 0 10px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
}
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 3px; }
input[type=text], select {
width: 100%;
background: var(--panel2);
border: 1px solid var(--border);
color: var(--fg);
padding: 7px 9px;
border-radius: 6px;
font-family: var(--mono);
font-size: 13px;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
.row { display: flex; gap: 8px; align-items: center; }
.row > * { flex: 1; }
.checkrow { display: flex; align-items: center; gap: 6px; margin: 10px 0; }
.checkrow input { flex: 0 0 auto; width: auto; }
.checkrow label { margin: 0; flex: 0 0 auto; }
button {
background: var(--accent);
border: none;
color: #fff;
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
margin-top: 8px;
}
button:hover { filter: brightness(1.12); }
button.ghost { background: var(--panel2); border: 1px solid var(--border); color: var(--fg); }
button.danger { background: #3a1d1d; border: 1px solid var(--red); color: var(--red); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.pill {
display: inline-block;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2px 9px;
font-size: 11px;
color: var(--muted);
}
.pill.on { color: var(--green); border-color: var(--green); }
.ident { word-break: break-all; font-size: 11px; color: var(--gold); margin-top: 6px; }
.copy {
cursor: pointer; color: var(--accent); font-size: 11px;
margin-left: 6px; text-decoration: underline;
}
#log {
background: #08090c;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
height: 520px;
overflow-y: auto;
font-size: 12.5px;
white-space: pre-wrap;
}
.msg { padding: 2px 0; border-bottom: 1px solid #11151b; }
.msg .subj { color: var(--accent); }
.msg .from { color: var(--gold); }
.msg .meta { color: var(--muted); font-size: 11px; }
.msg .enc { color: var(--green); }
.msg .clear { color: var(--muted); }
.sys { color: var(--muted); font-style: italic; }
.err { color: var(--red); }
.help {
background: var(--panel2);
border-left: 3px solid var(--accent);
padding: 10px 12px;
border-radius: 4px;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
.help b { color: var(--fg); }
.help code { color: var(--gold); }
.status { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
.status.ok { color: var(--green); }
.status.bad { color: var(--red); }
</style>
</head>
<body>
<header>
<h1>unibus playground</h1>
<span class="sub">embedded NATS + JetStream &middot; E2E rooms &middot; forward secrecy &middot; SSE</span>
</header>
<div class="wrap">
<!-- LEFT COLUMN: controls -->
<div class="col">
<div class="card">
<h2>1 &middot; Identity</h2>
<label>Peer name</label>
<div class="row">
<input id="peerName" type="text" placeholder="alice" autocomplete="off" />
<button id="connectBtn" style="flex:0 0 auto">Connect</button>
</div>
<div id="peerIdent" class="ident"></div>
<div id="connStatus" class="status"></div>
</div>
<div class="card">
<h2>2 &middot; Rooms</h2>
<label>Subject (e.g. room.general)</label>
<input id="roomSubject" type="text" placeholder="room.general" autocomplete="off" />
<div class="checkrow">
<input id="roomEncrypt" type="checkbox" />
<label for="roomEncrypt">&#128274; encrypted (E2E)</label>
</div>
<div class="checkrow">
<input id="roomPersist" type="checkbox" />
<label for="roomPersist">&#128450; persistente (historial)</label>
</div>
<div class="help" style="margin:-4px 0 8px; font-size:12px; color:var(--muted)">
persistente = quien se une despues ve el historial; sin persistir = solo mensajes nuevos (NATS simple).
</div>
<button id="createRoomBtn" disabled>Create room</button>
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
<label>Join by room_id</label>
<input id="joinRoomId" type="text" placeholder="01J..." autocomplete="off" />
<button id="joinBtn" class="ghost" disabled>Join</button>
<div id="roomStatus" class="status"></div>
</div>
<div class="card">
<h2>3 &middot; Action</h2>
<label>Active room</label>
<select id="activeRoom"></select>
<label>Message</label>
<div class="row">
<input id="msgText" type="text" placeholder="hello bus" autocomplete="off" />
<button id="sendBtn" style="flex:0 0 auto" disabled>Send</button>
</div>
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
<label>Target peer</label>
<div class="row">
<select id="targetPeer"></select>
<button id="refreshPeersBtn" class="ghost" style="flex:0 0 auto" title="reload peer list">&#8635;</button>
</div>
<button id="inviteBtn" disabled>Invite to this room</button>
<button id="kickBtn" class="danger" disabled>Kick from this room</button>
<div id="actionStatus" class="status"></div>
</div>
</div>
<!-- RIGHT COLUMN: live messages + help -->
<div class="col">
<div class="card" style="padding-bottom:8px">
<h2>Live messages <span id="streamPill" class="pill">disconnected</span></h2>
<div id="log"></div>
</div>
<div class="help">
<b>&#9432; How to try it</b><br />
Open <b>2 tabs</b>. Connect as <code>alice</code> in one and <code>bob</code> in the other.
In alice: create a <code>&#128274; encrypted</code> room, copy the <code>room_id</code>,
then pick <code>bob</code> as target and <b>Invite to this room</b>.
In bob: paste that <code>room_id</code> and <b>Join</b>.
Type in both &rarr; messages appear live on each side.
In alice: <b>Kick</b> bob &rarr; bob stops seeing new messages (forward secrecy: the room
key rotates and bob no longer holds it).
</div>
</div>
</div>
<!-- BENCHMARK: full-width performance simulator -->
<div style="padding: 0 20px 32px; max-width: 1200px;">
<div class="card">
<h2>Benchmark de rendimiento &middot; 1 publisher &rarr; N subscribers</h2>
<div style="display:flex; gap:26px; flex-wrap:wrap; align-items:flex-end; margin-bottom:6px;">
<div style="min-width:230px;">
<label>Mensajes a publicar &middot; <span id="bMsgsVal" style="color:var(--fg)">20 000</span></label>
<input id="bMsgs" type="range" min="1000" max="200000" step="1000" value="20000" style="width:100%; accent-color:var(--accent);" />
</div>
<div style="min-width:160px;">
<label>Subscribers &middot; <span id="bSubsVal" style="color:var(--fg)">3</span></label>
<input id="bSubs" type="range" min="1" max="16" step="1" value="3" style="width:100%; accent-color:var(--accent);" />
</div>
<div style="min-width:200px;">
<label>Tamaño payload &middot; <span id="bPayVal" style="color:var(--fg)">128 B</span></label>
<input id="bPay" type="range" min="16" max="8192" step="16" value="128" style="width:100%; accent-color:var(--accent);" />
</div>
<div class="checkrow" style="margin:0;">
<input id="bPersist" type="checkbox" />
<label for="bPersist">&#128450; JetStream (persistente)</label>
</div>
<div class="checkrow" style="margin:0;">
<input id="bEncrypt" type="checkbox" />
<label for="bEncrypt">&#128274; Encriptación E2E</label>
</div>
<button id="bRun" style="margin:0;">&#9654; Ejecutar benchmark</button>
</div>
<div class="help" style="margin:6px 0 12px;">
<b>JetStream</b> y <b>Encriptación</b> son ejes independientes: NATS core (ambos off) &middot; JetStream durable &middot; E2E (AEAD + firma Ed25519 por mensaje) &middot; E2E + JetStream. Los modos con cripto o persistencia se limitan a 30&nbsp;000 mensajes (cada mensaje paga cifrado/firma/ack).
</div>
<div style="display:flex; gap:30px; flex-wrap:wrap; margin:4px 2px 8px;">
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Enviados</div><div id="bSent" style="font-size:22px; color:var(--accent);">0</div></div>
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Recibidos (&Sigma; subs)</div><div id="bRecv" style="font-size:22px; color:var(--green);">0</div></div>
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Throughput recv</div><div id="bTps" style="font-size:22px; color:var(--gold);">0</div></div>
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Tiempo</div><div id="bTime" style="font-size:22px;">0.00 s</div></div>
</div>
<canvas id="bChart" style="width:100%; height:300px; display:block; background:#08090c; border:1px solid var(--border); border-radius:8px;"></canvas>
<div style="display:flex; gap:18px; font-size:12px; color:var(--muted); margin-top:6px;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--accent);margin-right:6px;"></span>enviados (publisher)</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--green);margin-right:6px;"></span>recibidos (suma de subscribers)</span>
</div>
<div id="bStatus" class="status" style="margin-top:8px;"></div>
</div>
</div>
<script>
"use strict";
const state = {
peer: null, // connected peer name
rooms: {}, // room_id -> {subject, encrypt}
es: null, // EventSource
};
const $ = (id) => document.getElementById(id);
async function api(path, body) {
const opts = { method: "POST", headers: { "Content-Type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
return data;
}
async function apiGet(path) {
const res = await fetch(path);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
return data;
}
function setStatus(id, msg, kind) {
const el = $(id);
el.textContent = msg || "";
el.className = "status" + (kind ? " " + kind : "");
}
function short(s, n = 10) {
if (!s) return "";
return s.length <= n * 2 ? s : s.slice(0, n) + "…" + s.slice(-4);
}
function hhmmss(ms) {
const d = new Date(ms);
const p = (x) => String(x).padStart(2, "0");
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
}
function logSys(text, cls) {
const log = $("log");
const div = document.createElement("div");
div.className = "msg " + (cls || "sys");
div.textContent = text;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
function logMsg(ev) {
const log = $("log");
const div = document.createElement("div");
div.className = "msg";
const enc = ev.encrypted
? '<span class="enc">&#128274;</span>'
: '<span class="clear">clear</span>';
div.innerHTML =
'<span class="subj">[' + escapeHtml(ev.subject) + ']</span> ' +
'<span class="from">' + escapeHtml(short(ev.sender)) + '</span> &#8614; ' +
escapeHtml(ev.text) +
' <span class="meta">&middot; ' + hhmmss(ev.ts) + ' &middot; ' + enc + '</span>';
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function refreshRoomSelect() {
const sel = $("activeRoom");
const cur = sel.value;
sel.innerHTML = "";
for (const [id, info] of Object.entries(state.rooms)) {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = info.subject + " (" + short(id, 6) + ")" + (info.encrypt ? " 🔒" : "");
sel.appendChild(opt);
}
if (state.rooms[cur]) sel.value = cur;
const has = Object.keys(state.rooms).length > 0;
$("sendBtn").disabled = !has;
$("inviteBtn").disabled = !has;
$("kickBtn").disabled = !has;
}
async function refreshPeers() {
try {
const peers = await apiGet("/api/peers");
const sel = $("targetPeer");
const cur = sel.value;
sel.innerHTML = "";
for (const p of peers) {
if (p.name === state.peer) continue; // don't target yourself
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = p.name + " (" + short(p.endpoint_id, 6) + ")";
sel.appendChild(opt);
}
if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
} catch (e) {
setStatus("actionStatus", "peers: " + e.message, "bad");
}
}
function openStream(name) {
if (state.es) state.es.close();
const es = new EventSource("/api/stream?peer=" + encodeURIComponent(name));
es.onopen = () => {
$("streamPill").textContent = "live: " + name;
$("streamPill").className = "pill on";
};
es.onmessage = (e) => {
try { logMsg(JSON.parse(e.data)); } catch (_) {}
};
es.onerror = () => {
$("streamPill").textContent = "reconnecting…";
$("streamPill").className = "pill";
};
state.es = es;
}
// ---- handlers ----
$("connectBtn").onclick = async () => {
const name = $("peerName").value.trim();
if (!name) { setStatus("connStatus", "enter a name", "bad"); return; }
try {
const res = await api("/api/peer", { name });
state.peer = res.name;
state.rooms = {};
refreshRoomSelect();
$("peerIdent").innerHTML =
'endpoint: ' + escapeHtml(res.endpoint_id) +
' <span class="copy" id="copyId">copy</span>';
$("copyId").onclick = () => navigator.clipboard.writeText(res.endpoint_id);
setStatus("connStatus", "connected as " + res.name, "ok");
$("createRoomBtn").disabled = false;
$("joinBtn").disabled = false;
$("log").innerHTML = "";
logSys("connected as " + res.name + " — listening for messages");
openStream(res.name);
refreshPeers();
} catch (e) {
setStatus("connStatus", e.message, "bad");
}
};
$("createRoomBtn").onclick = async () => {
const subject = $("roomSubject").value.trim();
const encrypt = $("roomEncrypt").checked;
const persist = $("roomPersist").checked;
if (!subject) { setStatus("roomStatus", "subject required", "bad"); return; }
try {
const res = await api("/api/room", { peer: state.peer, subject, encrypt, persist });
state.rooms[res.room_id] = { subject: res.subject, encrypt: res.encrypt };
refreshRoomSelect();
$("activeRoom").value = res.room_id;
setStatus("roomStatus", "created " + res.room_id + " (click to copy)", "ok");
$("roomStatus").style.cursor = "pointer";
$("roomStatus").onclick = () => navigator.clipboard.writeText(res.room_id);
logSys("created room " + res.subject + " [" + short(res.room_id) + "]" + (encrypt ? " 🔒" : "") + (res.persist ? " 🗄" : ""));
} catch (e) {
setStatus("roomStatus", e.message, "bad");
}
};
$("joinBtn").onclick = async () => {
const roomId = $("joinRoomId").value.trim();
if (!roomId) { setStatus("roomStatus", "room_id required", "bad"); return; }
try {
const res = await api("/api/join", { peer: state.peer, room_id: roomId });
state.rooms[roomId] = { subject: res.subject, encrypt: res.encrypt };
refreshRoomSelect();
$("activeRoom").value = roomId;
setStatus("roomStatus", "joined " + res.subject + (res.encrypt ? " 🔒" : ""), "ok");
logSys("joined room " + res.subject + " [" + short(roomId) + "]");
} catch (e) {
setStatus("roomStatus", e.message, "bad");
}
};
$("sendBtn").onclick = async () => {
const roomId = $("activeRoom").value;
const text = $("msgText").value;
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
try {
await api("/api/publish", { peer: state.peer, room_id: roomId, text });
$("msgText").value = "";
setStatus("actionStatus", "sent", "ok");
} catch (e) {
setStatus("actionStatus", e.message, "bad");
}
};
$("msgText").addEventListener("keydown", (e) => { if (e.key === "Enter") $("sendBtn").click(); });
$("inviteBtn").onclick = async () => {
const roomId = $("activeRoom").value;
const target = $("targetPeer").value;
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
if (!target) { setStatus("actionStatus", "no target peer (connect another peer first)", "bad"); return; }
try {
await api("/api/invite", { peer: state.peer, room_id: roomId, target });
setStatus("actionStatus", "invited " + target, "ok");
logSys("invited " + target + " to " + short(roomId));
} catch (e) {
setStatus("actionStatus", e.message, "bad");
}
};
$("kickBtn").onclick = async () => {
const roomId = $("activeRoom").value;
const target = $("targetPeer").value;
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
if (!target) { setStatus("actionStatus", "no target peer", "bad"); return; }
try {
await api("/api/kick", { peer: state.peer, room_id: roomId, target });
setStatus("actionStatus", "kicked " + target + " (key rotated)", "ok");
logSys("kicked " + target + " from " + short(roomId) + " — key rotated (forward secrecy)");
} catch (e) {
setStatus("actionStatus", e.message, "bad");
}
};
$("refreshPeersBtn").onclick = refreshPeers;
$("peerName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("connectBtn").click(); });
// ---- benchmark ----
const fmtN = (n) => Number(n).toLocaleString("es-ES");
const bMsgs = $("bMsgs"), bSubs = $("bSubs"), bPay = $("bPay");
bMsgs.oninput = () => $("bMsgsVal").textContent = fmtN(+bMsgs.value);
bSubs.oninput = () => $("bSubsVal").textContent = bSubs.value;
bPay.oninput = () => $("bPayVal").textContent = fmtN(+bPay.value) + " B";
let bSamples = [], bRunning = false, bES = null;
const bCanvas = $("bChart"), bCtx = bCanvas.getContext("2d");
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
function bResize() {
const dpr = window.devicePixelRatio || 1, r = bCanvas.getBoundingClientRect();
bCanvas.width = r.width * dpr; bCanvas.height = r.height * dpr;
bCtx.setTransform(dpr, 0, 0, dpr, 0, 0); bDraw();
}
window.addEventListener("resize", bResize);
function bDraw() {
const r = bCanvas.getBoundingClientRect(), W = r.width, H = r.height;
const padL = 70, padR = 14, padT = 12, padB = 26;
bCtx.clearRect(0, 0, W, H);
const tMax = bSamples.length ? Math.max(bSamples[bSamples.length - 1].t, 0.001) : 1;
const yMax = bSamples.length ? Math.max(...bSamples.map(s => Math.max(s.sent, s.recv)), 1) : 1;
bCtx.strokeStyle = "#2b333f"; bCtx.fillStyle = "#8b98a5"; bCtx.font = "11px ui-monospace";
for (let i = 0; i <= 5; i++) {
const yy = (H - padB) - (i / 5) * (H - padT - padB);
bCtx.beginPath(); bCtx.moveTo(padL, yy); bCtx.lineTo(W - padR, yy); bCtx.stroke();
bCtx.textAlign = "right"; bCtx.fillText(fmtN(Math.round((i / 5) * yMax)), padL - 8, yy + 3);
}
bCtx.textAlign = "center";
bCtx.fillText("0 s", padL, H - padB + 15);
bCtx.fillText(tMax.toFixed(2) + " s", W - padR, H - padB + 15);
if (bSamples.length < 2) return;
const x = (t) => padL + (t / tMax) * (W - padL - padR);
const y = (v) => (H - padB) - (v / yMax) * (H - padT - padB);
const line = (key, color) => {
bCtx.beginPath(); bCtx.lineWidth = 2.2; bCtx.strokeStyle = color;
bSamples.forEach((s, i) => { const px = x(s.t), py = y(s[key]); i ? bCtx.lineTo(px, py) : bCtx.moveTo(px, py); });
bCtx.stroke();
};
line("sent", cssVar("--accent"));
line("recv", cssVar("--green"));
}
function bSetRunning(v) { bRunning = v; $("bRun").disabled = v; }
$("bRun").onclick = () => {
if (bRunning) return;
bSamples = []; bSetRunning(true);
$("bSent").textContent = "0"; $("bRecv").textContent = "0"; $("bTps").textContent = "0"; $("bTime").textContent = "0.00 s";
setStatus("bStatus", "conectando…");
const qs = new URLSearchParams({
n_msgs: bMsgs.value, n_subs: bSubs.value, payload: bPay.value,
encrypt: $("bEncrypt").checked ? "1" : "0", persist: $("bPersist").checked ? "1" : "0",
});
const es = new EventSource("/api/bench?" + qs.toString());
bES = es;
const finish = () => { try { es.close(); } catch (_) {} bSetRunning(false); };
es.addEventListener("end", finish);
es.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
if (m.type === "start") {
setStatus("bStatus",
"corriendo… " + fmtN(m.n_msgs) + " msgs → " + m.n_subs + " subs · payload " + fmtN(m.payload) + "B"
+ (m.encrypt ? " · \u{1F512} E2E" : "") + (m.persist ? " · \u{1F5C4} JetStream" : "")
+ (m.capped ? " · (limitado a 30k)" : ""), "");
} else if (m.type === "sample") {
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv); $("bTime").textContent = m.t.toFixed(2) + " s";
if (bSamples.length >= 2) {
const a = bSamples[bSamples.length - 2], b = bSamples[bSamples.length - 1], dt = b.t - a.t;
if (dt > 0) $("bTps").textContent = fmtN(Math.round((b.recv - a.recv) / dt));
}
bDraw();
} else if (m.type === "done") {
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv);
$("bTps").textContent = fmtN(m.recv_tps); $("bTime").textContent = m.t.toFixed(2) + " s";
setStatus("bStatus",
"✓ " + m.t.toFixed(2) + "s · pub " + fmtN(m.pub_tps) + "/s · recv " + fmtN(m.recv_tps) + "/s · fan-out ×"
+ m.n_subs + " · por sub [" + (m.per_sub || []).map(fmtN).join(", ") + "]", "ok");
bDraw(); finish();
} else if (m.type === "error") {
setStatus("bStatus", "error: " + m.msg, "bad"); finish();
}
};
es.onerror = () => { if (bRunning) { setStatus("bStatus", "conexión SSE perdida", "bad"); finish(); } };
};
bResize();
</script>
</body>
</html>