182 lines
8.4 KiB
HTML
182 lines
8.4 KiB
HTML
|
|
<!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>
|