Files
Egutierrez b42f2b8255 feat: playground simulador de rendimiento NATS (webapp WebSocket + canvas)
Reemplaza el widget ipywidgets del notebook 04 (fragil: 'model not found' sin
re-ejecutar) por una webapp standalone. server.py corre el benchmark NATS y
transmite muestras por WebSocket; index.html dibuja la grafica en movimiento en
un canvas sin dependencias front. Reutiliza el venv del analisis.
Verificado: 100k msgs -> 4 subs = 400k entregas (~367k/s) en ~1.1s.
2026-06-03 22:02:08 +02:00

222 lines
8.5 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NATS · Simulador de rendimiento pub/sub</title>
<style>
:root {
--bg: #0b1020; --panel: #151b30; --line: #243049;
--blue: #3b82f6; --green: #22c55e; --pink: #ec4899; --txt: #e5e9f0; --muted: #8b95ad;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--txt);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
header { padding: 20px 28px 8px; }
h1 { margin: 0; font-size: 20px; font-weight: 650; }
.sub { color: var(--muted); font-size: 13px; margin-top: 4px; }
.wrap { max-width: 1000px; margin: 0 auto; padding: 0 28px 40px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; }
.controls { display: flex; gap: 28px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 16px; }
.ctl { display: flex; flex-direction: column; gap: 6px; }
.ctl label { font-size: 12px; color: var(--muted); }
.ctl .val { font-weight: 650; color: var(--txt); font-variant-numeric: tabular-nums; }
input[type=range] { width: 240px; accent-color: var(--blue); }
button {
background: var(--green); color: #06210f; border: 0; border-radius: 9px;
padding: 11px 20px; font-size: 14px; font-weight: 700; cursor: pointer;
}
button:disabled { opacity: .5; cursor: not-allowed; }
.stats { display: flex; gap: 26px; flex-wrap: wrap; margin: 16px 2px 4px; }
.stat { min-width: 120px; }
.stat .k { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.stat .v { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
canvas { width: 100%; height: 340px; display: block; margin-top: 8px; border-radius: 8px; }
.legend { display: flex; gap: 18px; font-size: 12px; color: var(--muted); margin-top: 6px; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.state { font-size: 13px; color: var(--muted); margin-left: 14px; }
.persub { font-size: 12px; color: var(--muted); margin-top: 8px; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<header>
<h1>NATS · Simulador de rendimiento pub/sub</h1>
<div class="sub">Un publisher inunda el broker con miles de mensajes que varios subscribers reciben (fan-out). La gráfica se mueve en tiempo real.</div>
</header>
<div class="wrap">
<div class="panel">
<div class="controls">
<div class="ctl">
<label>Mensajes a publicar</label>
<input id="msgs" type="range" min="1000" max="200000" step="1000" value="60000">
<span class="val" id="msgsVal">60 000</span>
</div>
<div class="ctl">
<label>Subscribers</label>
<input id="subs" type="range" min="1" max="12" step="1" value="3">
<span class="val" id="subsVal">3</span>
</div>
<div>
<button id="run">▶ Ejecutar benchmark</button>
<span class="state" id="state">listo</span>
</div>
</div>
<div class="stats">
<div class="stat"><div class="k">Enviados</div><div class="v" id="sent" style="color:var(--blue)">0</div></div>
<div class="stat"><div class="k">Recibidos (Σ subs)</div><div class="v" id="recv" style="color:var(--green)">0</div></div>
<div class="stat"><div class="k">Throughput recv</div><div class="v" id="tps" style="color:var(--pink)">0</div></div>
<div class="stat"><div class="k">Tiempo</div><div class="v" id="time">0.00 s</div></div>
</div>
<canvas id="chart"></canvas>
<div class="legend">
<span><span class="dot" style="background:var(--blue)"></span>enviados (publisher)</span>
<span><span class="dot" style="background:var(--green)"></span>recibidos (suma de subscribers)</span>
</div>
<div class="persub" id="persub"></div>
</div>
</div>
<script>
const WS_URL = `ws://${location.hostname}:7879`;
const $ = (id) => document.getElementById(id);
const fmt = (n) => n.toLocaleString("es-ES");
const msgs = $("msgs"), subs = $("subs");
const msgsVal = $("msgsVal"), subsVal = $("subsVal");
msgs.oninput = () => msgsVal.textContent = fmt(+msgs.value);
subs.oninput = () => subsVal.textContent = subs.value;
// --- estado del benchmark ---
let samples = []; // {t, sent, recv}
let running = false;
// --- canvas con soporte HiDPI ---
const canvas = $("chart"), ctx = canvas.getContext("2d");
function resize() {
const dpr = window.devicePixelRatio || 1;
const r = canvas.getBoundingClientRect();
canvas.width = r.width * dpr;
canvas.height = r.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
window.addEventListener("resize", resize);
function draw() {
const r = canvas.getBoundingClientRect();
const W = r.width, H = r.height;
const padL = 64, padR = 16, padT = 14, padB = 28;
ctx.clearRect(0, 0, W, H);
// fondo de la zona de plot
ctx.fillStyle = "#0e1426";
ctx.fillRect(padL, padT, W - padL - padR, H - padT - padB);
if (samples.length < 2) { drawAxes(W, H, padL, padR, padT, padB, 1, 1); return; }
const tMax = Math.max(samples[samples.length - 1].t, 0.001);
const yMax = Math.max(...samples.map(s => Math.max(s.sent, s.recv)), 1);
drawAxes(W, H, padL, padR, padT, padB, tMax, yMax);
const x = (t) => padL + (t / tMax) * (W - padL - padR);
const y = (v) => (H - padB) - (v / yMax) * (H - padT - padB);
const series = (key, color) => {
ctx.beginPath();
ctx.lineWidth = 2.4;
ctx.strokeStyle = color;
samples.forEach((s, i) => {
const px = x(s.t), py = y(s[key]);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
};
series("sent", getCSS("--blue"));
series("recv", getCSS("--green"));
}
function drawAxes(W, H, padL, padR, padT, padB, tMax, yMax) {
ctx.strokeStyle = "#243049"; ctx.fillStyle = "#8b95ad"; ctx.lineWidth = 1;
ctx.font = "11px ui-sans-serif, system-ui";
// Y gridlines (5)
for (let i = 0; i <= 5; i++) {
const yy = (H - padB) - (i / 5) * (H - padT - padB);
ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(W - padR, yy); ctx.stroke();
const val = Math.round((i / 5) * yMax);
ctx.textAlign = "right"; ctx.fillText(fmt(val), padL - 8, yy + 3);
}
// X labels (inicio / fin)
ctx.textAlign = "center";
ctx.fillText("0 s", padL, H - padB + 16);
ctx.fillText(tMax.toFixed(2) + " s", W - padR, H - padB + 16);
}
function getCSS(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
// --- WebSocket / control ---
$("run").onclick = () => {
if (running) return;
samples = [];
setRunning(true);
$("state").textContent = "conectando…";
$("persub").textContent = "";
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
$("state").textContent = "corriendo…";
ws.send(JSON.stringify({ action: "start", n_msgs: +msgs.value, n_subs: +subs.value }));
};
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === "sample" || m.type === "start") {
if (m.type === "sample") {
samples.push({ t: m.t, sent: m.sent, recv: m.recv });
$("sent").textContent = fmt(m.sent);
$("recv").textContent = fmt(m.recv);
$("time").textContent = m.t.toFixed(2) + " s";
if (samples.length >= 2) {
const a = samples[samples.length - 2], b = samples[samples.length - 1];
const dt = b.t - a.t;
if (dt > 0) $("tps").textContent = fmt(Math.round((b.recv - a.recv) / dt));
}
draw();
}
} else if (m.type === "done") {
samples.push({ t: m.t, sent: m.sent, recv: m.recv });
$("sent").textContent = fmt(m.sent);
$("recv").textContent = fmt(m.recv);
$("time").textContent = m.t.toFixed(2) + " s";
$("tps").textContent = fmt(m.recv_tps);
$("state").textContent = `✓ listo en ${m.t.toFixed(2)} s · pub ${fmt(m.pub_tps)} msgs/s · recv ${fmt(m.recv_tps)} msgs/s`;
$("persub").textContent = `por subscriber: [${m.per_sub.map(fmt).join(", ")}] · fan-out ×${m.n_subs ?? m.per_sub.length} = ${fmt(m.recv)} entregas totales`;
draw();
ws.close();
setRunning(false);
} else if (m.type === "error") {
$("state").textContent = "error: " + m.msg;
ws.close();
setRunning(false);
}
};
ws.onerror = () => { $("state").textContent = "error de conexión WebSocket"; setRunning(false); };
ws.onclose = () => { if (running) setRunning(false); };
};
function setRunning(v) {
running = v;
$("run").disabled = v;
}
resize();
</script>
</body>
</html>