b42f2b8255
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.
222 lines
8.5 KiB
HTML
222 lines
8.5 KiB
HTML
<!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>
|