Files
lab/labsim/topology.html

182 lines
8.4 KiB
HTML
Raw Permalink Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>labsim — live VLAN topology</title>
<style>
:root {
--bg:#0e1116; --panel:#161b22; --line:#30363d; --text:#e6edf3; --dim:#8b949e;
--ok:#3fb950; --bad:#f85149; --warn:#d29922; --router:#58a6ff;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text);
font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
header { display:flex; align-items:baseline; gap:16px; flex-wrap:wrap;
padding:14px 20px; border-bottom:1px solid var(--line); }
h1 { font-size:16px; margin:0; font-weight:650; letter-spacing:.2px; }
.meta { color:var(--dim); font-size:12px; }
.pill { padding:2px 8px; border-radius:999px; font-size:12px; font-weight:600; }
.pill.ok { background:rgba(63,185,80,.15); color:var(--ok); }
.pill.bad { background:rgba(248,81,73,.15); color:var(--bad); }
main { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(320px,.65fr);
gap:16px; padding:16px 20px; align-items:start; }
@media (max-width:1000px){ main { grid-template-columns:1fr; } }
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px; }
.card h2 { margin:0 0 10px; font-size:13px; font-weight:600; color:var(--dim);
text-transform:uppercase; letter-spacing:.6px; }
svg { width:100%; height:auto; display:block; }
.edge { stroke-width:2.5; transition:stroke .25s, opacity .25s; }
.edge.ok { stroke:var(--ok); opacity:.55; }
.edge.bad { stroke:var(--bad); opacity:.95; stroke-dasharray:7 5; }
.edge:hover { opacity:1; stroke-width:4; }
.node circle { fill:#0d1117; stroke-width:2.5; }
.node text { text-anchor:middle; font-size:11px; font-weight:600; fill:var(--text); }
.node .sub { font-size:9.5px; font-weight:400; fill:var(--dim); }
.rtt { font-size:9px; fill:var(--dim); text-anchor:middle; }
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th,td { text-align:left; padding:5px 8px; border-bottom:1px solid var(--line); }
th { color:var(--dim); font-weight:600; font-size:11px; text-transform:uppercase; }
td.n { text-align:right; font-variant-numeric:tabular-nums; }
.b-ok { color:var(--ok); } .b-bad { color:var(--bad); }
.empty { color:var(--dim); padding:10px 4px; }
.legend { display:flex; gap:14px; align-items:center; color:var(--dim);
font-size:11.5px; margin-top:10px; flex-wrap:wrap; }
.swatch { display:inline-block; width:22px; height:0; border-top:2.5px solid; margin-right:5px;
vertical-align:middle; }
</style>
</head>
<body>
<header>
<h1>labsim — live VLAN topology</h1>
<span id="summary" class="pill ok"></span>
<span class="meta">every path is probed <em>from</em> a VM <em>to</em> every other VM, through the VyOS router</span>
<span class="meta" id="clock" style="margin-left:auto"></span>
</header>
<main>
<section class="card">
<h2>Mesh — line colour is reachability, label is ICMP RTT</h2>
<svg id="topo" viewBox="0 0 720 560" role="img" aria-label="VLAN topology"></svg>
<div class="legend">
<span><i class="swatch" style="border-color:var(--ok)"></i>reachable</span>
<span><i class="swatch" style="border-color:var(--bad); border-top-style:dashed"></i>blocked</span>
<span>hover a line for detail · node ring turns red if anything to/from it is blocked</span>
</div>
</section>
<aside style="display:grid; gap:16px">
<section class="card">
<h2>Blocked paths</h2>
<div id="blocked"></div>
</section>
<section class="card">
<h2>Latency (ICMP, ms)</h2>
<table><thead><tr><th>path</th><th class="n">rtt</th></tr></thead>
<tbody id="lat"></tbody></table>
</section>
</aside>
</main>
<script>
const REFRESH_MS = 5000;
const CX = 360, CY = 250, R = 185;
function polar(i, n) {
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
return { x: CX + R * Math.cos(a), y: CY + R * Math.sin(a) };
}
function render(data) {
const vlans = data.vlans, res = data.results;
const svg = document.getElementById('topo');
const n = vlans.length;
const pos = vlans.map((_, i) => polar(i, n));
let out = '';
// Router in the middle — every inter-VLAN packet really does traverse it.
out += `<circle cx="${CX}" cy="${CY}" r="40" fill="#0d1117" stroke="var(--router)" stroke-width="2.5"/>`;
out += `<text x="${CX}" y="${CY-6}" text-anchor="middle" font-size="12" font-weight="700" fill="var(--router)">VyOS</text>`;
out += `<text x="${CX}" y="${CY+9}" text-anchor="middle" font-size="8.5" fill="var(--dim)">bond0</text>`;
out += `<text x="${CX}" y="${CY+20}" text-anchor="middle" font-size="8.5" fill="var(--dim)">LACP</text>`;
const bad = new Set();
// One line per unordered pair; a pair is bad if EITHER direction fails.
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = vlans[i].label, b = vlans[j].label;
const ab = (res[a] || {})[b] || {}, ba = (res[b] || {})[a] || {};
const okAB = ab.icmp === true, okBA = ba.icmp === true;
const ok = okAB && okBA;
if (!ok) { bad.add(a); bad.add(b); }
const rtts = [ab.rtt_ms, ba.rtt_ms].filter(v => typeof v === 'number');
const rtt = rtts.length ? (rtts.reduce((s,v)=>s+v,0)/rtts.length) : null;
// Place the label ~32% along the edge, not at the midpoint: diagonals of
// a 6-node mesh all cross the centre, so midpoint labels stack on top of
// the router node. Plus a small perpendicular nudge off the line itself.
const dx = pos[j].x - pos[i].x, dy = pos[j].y - pos[i].y;
const len = Math.hypot(dx, dy) || 1;
const t = 0.32;
const mx = pos[i].x + dx * t + (-dy / len) * 8;
const my = pos[i].y + dy * t + ( dx / len) * 8;
const tip = `${a} ↔ ${b}\n→ ${okAB ? 'ok' : 'BLOCKED'} ← ${okBA ? 'ok' : 'BLOCKED'}` +
(rtt !== null ? `\nrtt ${rtt.toFixed(2)} ms` : '');
out += `<line class="edge ${ok?'ok':'bad'}" x1="${pos[i].x}" y1="${pos[i].y}" x2="${pos[j].x}" y2="${pos[j].y}"><title>${tip}</title></line>`;
if (ok && rtt !== null)
out += `<text class="rtt" x="${mx}" y="${my}">${rtt.toFixed(2)}</text>`;
}
}
vlans.forEach((v, i) => {
const p = pos[i], isBad = bad.has(v.label);
out += `<g class="node"><circle cx="${p.x}" cy="${p.y}" r="30" stroke="${isBad?'var(--bad)':'var(--ok)'}"/>` +
`<text x="${p.x}" y="${p.y-2}">${v.name}</text>` +
`<text class="sub" x="${p.x}" y="${p.y+11}">vlan ${v.vid}</text>` +
`<text class="sub" x="${p.x}" y="${p.y+47}">${v.ip}</text></g>`;
});
svg.innerHTML = out;
// Blocked list — the thing you actually act on.
const rows = [];
for (const src of vlans) for (const dst of vlans) {
if (src.label === dst.label) continue;
const d = (res[src.label] || {})[dst.label] || {};
for (const proto of ['icmp','tcp22','tcp80'])
if (d[proto] === false) rows.push(`${src.label} → ${dst.label} <span style="color:var(--dim)">(${proto})</span>`);
}
document.getElementById('blocked').innerHTML = rows.length
? `<table><tbody>${rows.map(r=>`<tr><td class="b-bad">${r}</td></tr>`).join('')}</tbody></table>`
: `<div class="empty">none — all ${vlans.length*(vlans.length-1)*3} paths open</div>`;
// Latency table, slowest first.
const lat = [];
for (const src of vlans) for (const dst of vlans) {
if (src.label === dst.label) continue;
const d = (res[src.label] || {})[dst.label] || {};
if (typeof d.rtt_ms === 'number') lat.push([`${src.label} → ${dst.label}`, d.rtt_ms]);
}
lat.sort((a,b) => b[1]-a[1]);
document.getElementById('lat').innerHTML = lat.slice(0,12)
.map(([k,v]) => `<tr><td>${k}</td><td class="n">${v.toFixed(2)}</td></tr>`).join('')
|| `<tr><td class="empty" colspan="2">no RTT data</td></tr>`;
const total = data.total, reach = data.reachable;
const pill = document.getElementById('summary');
pill.textContent = `${reach}/${total} paths open`;
pill.className = 'pill ' + (reach === total ? 'ok' : 'bad');
document.getElementById('clock').textContent =
`updated ${new Date().toLocaleTimeString()} · sweep ${data.sweep_seconds.toFixed(2)}s · refresh ${REFRESH_MS/1000}s`;
}
async function tick() {
try {
const r = await fetch('/api/matrix', {cache:'no-store'});
render(await r.json());
} catch (e) {
document.getElementById('clock').textContent = 'exporter unreachable — ' + e;
}
}
tick(); setInterval(tick, REFRESH_MS);
</script>
</body>
</html>