6b162deeb0
Añade GET /api/bench (SSE) y una seccion de simulador en index.html: un publisher inunda una room con miles de mensajes a N subscribers y una grafica en vivo anima el throughput. Las dos politicas de room se exponen como flags independientes (persist=JetStream, encrypt=E2E AEAD+Ed25519) mas tamano de payload, midiendo el coste de cada capa con la libreria cliente real. El benchmark usa peers efimeros propios, sin tocar los peers nombrados del sandbox manual. Verificado: las 4 combinaciones enc x persist con fan-out exacto. Bump app v0.2.0.
595 lines
23 KiB
HTML
595 lines
23 KiB
HTML
<!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 · E2E rooms · forward secrecy · SSE</span>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<!-- LEFT COLUMN: controls -->
|
||
<div class="col">
|
||
<div class="card">
|
||
<h2>1 · 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 · 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">🔒 encrypted (E2E)</label>
|
||
</div>
|
||
<div class="checkrow">
|
||
<input id="roomPersist" type="checkbox" />
|
||
<label for="roomPersist">🗂 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 · 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">↻</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>ⓘ 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>🔒 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 → messages appear live on each side.
|
||
In alice: <b>Kick</b> bob → 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 · 1 publisher → 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 · <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 · <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 · <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">🗂 JetStream (persistente)</label>
|
||
</div>
|
||
<div class="checkrow" style="margin:0;">
|
||
<input id="bEncrypt" type="checkbox" />
|
||
<label for="bEncrypt">🔒 Encriptación E2E</label>
|
||
</div>
|
||
<button id="bRun" style="margin:0;">▶ 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) · JetStream durable · E2E (AEAD + firma Ed25519 por mensaje) · E2E + JetStream. Los modos con cripto o persistencia se limitan a 30 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 (Σ 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">🔒</span>'
|
||
: '<span class="clear">clear</span>';
|
||
div.innerHTML =
|
||
'<span class="subj">[' + escapeHtml(ev.subject) + ']</span> ' +
|
||
'<span class="from">' + escapeHtml(short(ev.sender)) + '</span> ↦ ' +
|
||
escapeHtml(ev.text) +
|
||
' <span class="meta">· ' + hhmmss(ev.ts) + ' · ' + enc + '</span>';
|
||
log.appendChild(div);
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||
}[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>
|