The control plane asks one node on each physical fabric to run
ibnetdiscover every five minutes and parses the text itself, so a
format the parser has not met is a control plane upgrade, not one on
every node. From the map it works out the switch tiers (leaf, spine,
core, by distance from the adapters), which adapter is which node's,
which cables trained below the rate the rest of their kind run at, and
whether the rails line up: every node's Nth adapter on its own leaf.
A page per fabric (/admin/fabrics) draws it: switches in their tiers,
adapters fanned out under their leaves, cables coloured by rate and
bundled, degraded ones marching red with a marker, and the cluster's
own adapters live - down ports glowing red, and particles running each
cable as fast as its adapter is sending and receiving, from the
counters. It zooms, pans, fades what a hovered switch or adapter does
not touch, opens a panel of detail on a click, colours by rail, finds a
node, and refreshes every fifteen seconds. The data is also at
/api/v1/fabrics/{id}/topology.
Placement reads the map: a replica spanning nodes takes nodes of one
leaf together when it can, and nodes whose cable trained short last.
Static assets are named by their content, as the stylesheet already
was.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
448 lines
24 KiB
JavaScript
448 lines
24 KiB
JavaScript
// The fabric map: switches in their tiers, adapters under their leaves, every
|
||
// cable between them, and the cluster's own adapters live.
|
||
//
|
||
// Plain SVG and no library, like every other chart here. The layout is the
|
||
// shape a fat tree already has — cores over spines over leaves over hosts —
|
||
// so it is computed rather than simulated, and it is the same every time the
|
||
// page is opened: a map that rearranges itself on each refresh is one nobody
|
||
// can learn.
|
||
(function () {
|
||
"use strict";
|
||
const root = document.getElementById("fabric-map");
|
||
if (!root) return;
|
||
const seed = document.getElementById("fabric-data");
|
||
let data = seed ? JSON.parse(seed.textContent) : null;
|
||
const url = root.dataset.src;
|
||
const svgNS = "http://www.w3.org/2000/svg";
|
||
const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
|
||
const state = { mode: location.hash === "#rails" ? "rails" : "speed", traffic: !reduced, problems: false, query: "", selected: null,
|
||
view: { x: 0, y: 0, k: 1 } };
|
||
|
||
const canvas = root.querySelector(".fm-canvas");
|
||
const tip = root.querySelector(".fm-tip");
|
||
const panel = root.querySelector(".fm-panel");
|
||
const stats = root.querySelector(".fm-stats");
|
||
|
||
// --- colours ---------------------------------------------------------
|
||
const speedClass = (g) => g >= 800 ? "xdr" : g >= 400 ? "ndr" : g >= 200 ? "hdr" : g >= 100 ? "edr" : "slow";
|
||
const railPalette = ["#38bdf8", "#a78bfa", "#34d399", "#fbbf24", "#f472b6", "#fb7185", "#2dd4bf", "#c084fc"];
|
||
const fmtBps = (b) => {
|
||
const bits = b * 8;
|
||
if (bits >= 1e9) return (bits / 1e9).toFixed(1) + " Gb/s";
|
||
if (bits >= 1e6) return (bits / 1e6).toFixed(1) + " Mb/s";
|
||
if (bits >= 1e3) return (bits / 1e3).toFixed(0) + " kb/s";
|
||
return bits.toFixed(0) + " b/s";
|
||
};
|
||
const el = (name, attrs, parent) => {
|
||
const e = document.createElementNS(svgNS, name);
|
||
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
||
if (parent) parent.appendChild(e);
|
||
return e;
|
||
};
|
||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
|
||
// --- layout ----------------------------------------------------------
|
||
function layout(map, width) {
|
||
const tiers = Math.max(1, map.tiers || 1);
|
||
// Rows are placed once the width is known; see below.
|
||
let gap = 150;
|
||
const rowY = (t) => 70 + (tiers - t) * gap;
|
||
const pos = {};
|
||
const byGUID = {};
|
||
map.switches.forEach((s) => (byGUID[s.guid] = s));
|
||
const leaves = map.switches.filter((s) => s.tier === 1).sort((a, b) => a.name.localeCompare(b.name));
|
||
|
||
// Hosts under their leaves, leaf by leaf, a little gap between groups.
|
||
const groups = new Map(leaves.map((l) => [l.guid, []]));
|
||
const loose = [];
|
||
map.hosts.forEach((h) => (groups.has(h.leaf) ? groups.get(h.leaf) : loose).push(h));
|
||
const hostGap = map.hosts.length > 120 ? 16 : map.hosts.length > 48 ? 24 : 34;
|
||
// The width decides the height: the drawing is fitted to the page, and a
|
||
// fabric four times wider than the page would be a thin strip at the
|
||
// height of a small one.
|
||
const hostCount = map.hosts.length;
|
||
const estWidth = Math.max(width, 60 + hostCount * hostGap + leaves.length * 90);
|
||
gap = Math.max(130, (estWidth * 0.42 - 180) / tiers);
|
||
const hostY = rowY(1) + gap;
|
||
// Room on the left for the tier names, at the size they will be drawn.
|
||
let x = 40 + 30 * Math.max(1, estWidth / width);
|
||
const leafX = {};
|
||
for (const l of leaves) {
|
||
const hs = groups.get(l.guid);
|
||
if (!hs.length) { leafX[l.guid] = x + 50; x += 130; continue; }
|
||
const start = x;
|
||
hs.forEach((h) => { pos[h.guid] = { x, y: hostY }; x += hostGap; });
|
||
leafX[l.guid] = (start + x - hostGap) / 2;
|
||
x += Math.max(40, 130 - hs.length * hostGap / 2);
|
||
}
|
||
loose.forEach((h) => { pos[h.guid] = { x, y: hostY }; x += hostGap; });
|
||
const span = Math.max(x + 20, width);
|
||
// Leaves too close for their boxes are spread, keeping their order.
|
||
let last = -Infinity;
|
||
for (const l of leaves) {
|
||
const want = Math.max(leafX[l.guid], last + 124);
|
||
pos[l.guid] = { x: want, y: rowY(1) };
|
||
last = want;
|
||
}
|
||
// Each tier above: ordered by where the switches they connect to sit,
|
||
// which keeps cables from crossing more than they must, then spread
|
||
// evenly across the width.
|
||
const adj = {};
|
||
map.links.forEach((k) => {
|
||
(adj[k.from] = adj[k.from] || []).push(k.to);
|
||
(adj[k.to] = adj[k.to] || []).push(k.from);
|
||
});
|
||
for (let t = 2; t <= tiers; t++) {
|
||
const row = map.switches.filter((s) => s.tier === t);
|
||
row.forEach((s) => {
|
||
const xs = (adj[s.guid] || []).map((g) => pos[g]).filter((p) => p && p.y > rowY(t)).map((p) => p.x);
|
||
s._bx = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : span / 2;
|
||
});
|
||
row.sort((a, b) => a._bx - b._bx || a.name.localeCompare(b.name));
|
||
const lo = Math.min(...leaves.map((l) => pos[l.guid].x), span * 0.15);
|
||
const hi = Math.max(...leaves.map((l) => pos[l.guid].x), span * 0.85);
|
||
row.forEach((s, i) => {
|
||
pos[s.guid] = { x: row.length === 1 ? (lo + hi) / 2 : lo + (hi - lo) * i / (row.length - 1), y: rowY(t) };
|
||
});
|
||
}
|
||
map.switches.forEach((s) => { if (!pos[s.guid]) pos[s.guid] = { x: span / 2, y: rowY(s.tier || 1) }; });
|
||
// How much to enlarge switches and adapters so they read at the size
|
||
// they would on a page as wide as the fabric — as far as their spacing
|
||
// lets them without touching.
|
||
const fit = Math.max(1, Math.max(span, width) / width);
|
||
let leafGap = Infinity;
|
||
for (let i = 1; i < leaves.length; i++) leafGap = Math.min(leafGap, pos[leaves[i].guid].x - pos[leaves[i - 1].guid].x);
|
||
const swScale = Math.min(fit, leafGap / 118, 2.4);
|
||
const hostScale = Math.min(fit, hostGap / 13, 2.4);
|
||
return { pos, width: Math.max(span, width), height: hostY + 110 * Math.max(1, hostScale), rowY, tiers, byGUID,
|
||
gap, swScale: Math.max(1, swScale), hostScale: Math.max(1, hostScale), fit };
|
||
}
|
||
|
||
// --- drawing ---------------------------------------------------------
|
||
let svg, viewport, flowScale = 1;
|
||
function draw() {
|
||
canvas.textContent = "";
|
||
if (!data || !data.map) {
|
||
const msg = data && data.error ? "The map could not be made: " + data.error
|
||
: "Not mapped yet: the first scan runs within five minutes of a node on this fabric connecting.";
|
||
canvas.innerHTML = '<p class="fm-empty">' + esc(msg) + "</p>";
|
||
fillStats();
|
||
return;
|
||
}
|
||
const map = data.map;
|
||
const width = canvas.clientWidth || 1000;
|
||
const L = layout(map, width);
|
||
// Fitted to the width to start with — the whole fabric at once — and
|
||
// zoomed from there for detail.
|
||
svg = el("svg", { class: "fm-svg mode-" + state.mode + (state.problems ? " only-problems" : ""),
|
||
viewBox: `0 0 ${L.width} ${L.height}`, preserveAspectRatio: "xMidYMin meet" });
|
||
svg.style.height = Math.max(380, Math.min(780, L.height * width / L.width)) + "px";
|
||
const defs = el("defs", {}, svg);
|
||
defs.innerHTML = `
|
||
<filter id="fm-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||
<feGaussianBlur stdDeviation="3.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
<linearGradient id="fm-g-leaf" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2dd4bf" stop-opacity=".30"/><stop offset="1" stop-color="#0f766e" stop-opacity=".18"/></linearGradient>
|
||
<linearGradient id="fm-g-spine" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#a78bfa" stop-opacity=".34"/><stop offset="1" stop-color="#5b21b6" stop-opacity=".18"/></linearGradient>
|
||
<linearGradient id="fm-g-core" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fbbf24" stop-opacity=".34"/><stop offset="1" stop-color="#92400e" stop-opacity=".18"/></linearGradient>
|
||
<pattern id="fm-grid" width="28" height="28" patternUnits="userSpaceOnUse"><path d="M28 0H0V28" fill="none" class="fm-gridline"/></pattern>`;
|
||
el("rect", { x: -5000, y: -5000, width: 20000, height: 20000, fill: "url(#fm-grid)" }, svg);
|
||
viewport = el("g", { class: "fm-viewport" }, svg);
|
||
|
||
// Tier labels down the left.
|
||
const names = { 1: "leaf", 2: "spine", 3: "core", 4: "super-core" };
|
||
// Tier names down the left edge, turned on their side so they take no
|
||
// room from the leftmost switch.
|
||
const label = (y, text) => el("text", { class: "fm-tier",
|
||
transform: `translate(${14 * L.fit},${y}) rotate(-90) scale(${L.fit})` }, viewport).textContent = text;
|
||
for (let t = L.tiers; t >= 1; t--) label(L.rowY(t), names[t] || "tier " + t);
|
||
label(L.rowY(1) + L.gap, "adapters");
|
||
|
||
// Cables, parallel ones bundled.
|
||
const bundles = new Map();
|
||
for (const k of map.links) {
|
||
const key = k.from + "|" + k.to;
|
||
const b = bundles.get(key) || { from: k.from, to: k.to, links: [] };
|
||
b.links.push(k);
|
||
bundles.set(key, b);
|
||
}
|
||
const linkLayer = el("g", { class: "fm-links" }, viewport);
|
||
const flowLayer = el("g", { class: "fm-flows" }, viewport);
|
||
flowScale = L.hostScale;
|
||
const touches = {};
|
||
for (const b of bundles.values()) {
|
||
const a = L.pos[b.from], z = L.pos[b.to];
|
||
if (!a || !z) continue;
|
||
const my = (a.y + z.y) / 2;
|
||
const d = `M${a.x},${a.y + 17 * L.swScale} C${a.x},${my} ${z.x},${my} ${z.x},${z.y - (b.links[0].host ? 8 * L.hostScale : 17 * L.swScale)}`;
|
||
const bad = b.links.some((k) => k.degraded);
|
||
const g = Math.min(...b.links.map((k) => k.gbps || 0));
|
||
const host = b.links[0].host;
|
||
const hostInfo = host ? map.hosts.find((h) => h.guid === b.to) : null;
|
||
const path = el("path", { d, class: "fm-link sp-" + speedClass(g) + (bad ? " degraded" : "") + (host ? " host" : ""),
|
||
"stroke-width": host ? 1.4 : 1.2 + Math.log2(b.links.length) * 1.4 }, linkLayer);
|
||
if (host && hostInfo) path.style.setProperty("--rail", railPalette[hostInfo.rail % railPalette.length]);
|
||
path.dataset.ends = b.from + " " + b.to;
|
||
path.addEventListener("mouseenter", (e) => showTip(e, linkTip(b, map)));
|
||
path.addEventListener("mouseleave", hideTip);
|
||
(touches[b.from] = touches[b.from] || []).push(path);
|
||
(touches[b.to] = touches[b.to] || []).push(path);
|
||
if (bad) {
|
||
(touches[b.from].bad = (touches[b.from].bad || 0) + 1);
|
||
const mid = pointAt(a, z, my);
|
||
const w = el("g", { class: "fm-warn", transform: `translate(${mid.x},${mid.y}) scale(${L.swScale})` }, viewport);
|
||
el("circle", { r: 7 }, w);
|
||
el("text", { y: 3.5 }, w).textContent = "!";
|
||
w.addEventListener("mouseenter", (e) => showTip(e, linkTip(b, map)));
|
||
w.addEventListener("mouseleave", hideTip);
|
||
}
|
||
// Traffic: particles that run as fast as the adapter is busy — up
|
||
// the cable for what it sends, down for what it receives.
|
||
if (host && state.traffic && data.live && data.live[b.to]) {
|
||
const live = data.live[b.to];
|
||
flow(flowLayer, d, live.tx_bps, "up", live.gbps);
|
||
flow(flowLayer, d, live.rx_bps, "down", live.gbps);
|
||
}
|
||
}
|
||
|
||
// Switches.
|
||
const nodeLayer = el("g", { class: "fm-nodes" }, viewport);
|
||
for (const s of map.switches) {
|
||
const p = L.pos[s.guid];
|
||
const tierName = s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf";
|
||
const g = el("g", { class: "fm-switch t-" + tierName + (touches[s.guid] && touches[s.guid].bad ? " has-bad" : ""),
|
||
transform: `translate(${p.x},${p.y}) scale(${L.swScale})`, tabindex: 0 }, nodeLayer);
|
||
el("rect", { x: -56, y: -17, width: 112, height: 34, rx: 10, fill: `url(#fm-g-${tierName})` }, g);
|
||
el("text", { class: "fm-name", y: -1 }, g).textContent = s.name.length > 16 ? s.name.slice(0, 15) + "…" : s.name;
|
||
el("text", { class: "fm-sub", y: 11 }, g).textContent = `${s.used}/${s.ports} ports` + (s.hosts ? ` · ${s.hosts} hosts` : "");
|
||
// Port use as an arc along the bottom edge.
|
||
const frac = s.ports ? Math.min(1, s.used / s.ports) : 0;
|
||
el("line", { x1: -46, y1: 15.5, x2: -46 + 92 * frac, y2: 15.5, class: "fm-use" }, g);
|
||
if (touches[s.guid] && touches[s.guid].bad) {
|
||
const bdg = el("g", { class: "fm-badge", transform: "translate(52,-15)" }, g);
|
||
el("circle", { r: 8 }, bdg);
|
||
el("text", { y: 3.5 }, bdg).textContent = touches[s.guid].bad;
|
||
}
|
||
wire(g, s.guid, () => switchTip(s, map, touches), () => switchPanel(s, map));
|
||
}
|
||
|
||
// Adapters.
|
||
const showLabels = map.hosts.length <= 96;
|
||
for (const h of map.hosts) {
|
||
const p = L.pos[h.guid];
|
||
if (!p) continue;
|
||
const live = data.live && data.live[h.guid];
|
||
const down = live && live.state && live.state.toUpperCase() !== "ACTIVE";
|
||
const errs = live && live.errors && Object.keys(live.errors).length;
|
||
const cls = "fm-host" + (h.node ? " known" : "") + (down ? " down" : "") + (errs ? " errs" : "") +
|
||
(state.query && matches(h) ? " found" : "");
|
||
const g = el("g", { class: cls, transform: `translate(${p.x},${p.y}) scale(${L.hostScale})`, tabindex: 0 }, nodeLayer);
|
||
g.style.setProperty("--rail", railPalette[h.rail % railPalette.length]);
|
||
el("circle", { r: 7.5, class: "fm-halo" }, g);
|
||
el("circle", { r: 5.5 }, g);
|
||
if (showLabels) {
|
||
const label = (h.node || (h.desc || "").split(" ")[0] || h.guid.slice(-6)) + (h.hca ? " " + h.hca.replace("mlx5_", "#") : "");
|
||
el("text", { class: "fm-hlabel", transform: "translate(3,14) rotate(55)" }, g).textContent = label;
|
||
}
|
||
wire(g, h.guid, () => hostTip(h, live), () => hostPanel(h, live, map));
|
||
}
|
||
|
||
canvas.appendChild(svg);
|
||
applyView();
|
||
enablePanZoom(svg, L);
|
||
if (state.selected) highlight(state.selected, touches);
|
||
svg._touches = touches;
|
||
fillStats();
|
||
}
|
||
|
||
function pointAt(a, z, my) {
|
||
// The middle of the cubic: its two control points share a height.
|
||
return { x: (a.x + z.x) / 2, y: my };
|
||
}
|
||
|
||
function flow(layer, d, bps, dir, gbps) {
|
||
if (!bps || bps < 1e3) return;
|
||
const cap = (gbps || 100) * 1e9 / 8;
|
||
const load = Math.min(1, bps / cap);
|
||
const count = 1 + Math.round(load * 4);
|
||
const dur = Math.max(0.7, 4.5 - Math.log10(bps) * 0.38);
|
||
for (let i = 0; i < count; i++) {
|
||
const c = el("circle", { r: 2.4 * flowScale, class: "fm-particle " + dir }, layer);
|
||
const m = el("animateMotion", { dur: dur + "s", repeatCount: "indefinite", path: d,
|
||
begin: (-(dur / count) * i).toFixed(2) + "s" }, c);
|
||
if (dir === "up") { m.setAttribute("keyPoints", "1;0"); m.setAttribute("keyTimes", "0;1"); m.setAttribute("calcMode", "linear"); }
|
||
}
|
||
}
|
||
|
||
// --- interaction ------------------------------------------------------
|
||
function wire(g, guid, tipFn, panelFn) {
|
||
g._guid = guid;
|
||
g.addEventListener("mouseenter", (e) => { highlight(guid, svg._touches); showTip(e, tipFn()); });
|
||
g.addEventListener("mousemove", moveTip);
|
||
g.addEventListener("mouseleave", () => { hideTip(); if (!state.selected) unhighlight(); else highlight(state.selected, svg._touches); });
|
||
const open = (e) => { e.stopPropagation(); state.selected = guid; highlight(guid, svg._touches); panel.innerHTML = panelFn(); panel.hidden = false; };
|
||
g.addEventListener("click", open);
|
||
g.addEventListener("keydown", (e) => { if (e.key === "Enter") open(e); });
|
||
}
|
||
function highlight(guid, touches) {
|
||
if (!svg) return;
|
||
svg.classList.add("focus");
|
||
svg.querySelectorAll(".hot").forEach((n) => n.classList.remove("hot"));
|
||
(touches[guid] || []).forEach((p) => {
|
||
p.classList.add("hot");
|
||
p.dataset.ends.split(" ").forEach((g) => {
|
||
svg.querySelectorAll(".fm-nodes > g").forEach((n) => { if (n._guid === g) n.classList.add("hot"); });
|
||
});
|
||
});
|
||
svg.querySelectorAll(".fm-nodes > g").forEach((n) => { if (n._guid === guid) n.classList.add("hot"); });
|
||
}
|
||
function unhighlight() {
|
||
if (!svg) return;
|
||
svg.classList.remove("focus");
|
||
svg.querySelectorAll(".hot").forEach((n) => n.classList.remove("hot"));
|
||
}
|
||
function showTip(e, html) { tip.innerHTML = html; tip.hidden = false; moveTip(e); }
|
||
function moveTip(e) {
|
||
const r = root.getBoundingClientRect();
|
||
tip.style.left = Math.min(e.clientX - r.left + 14, r.width - 260) + "px";
|
||
tip.style.top = (e.clientY - r.top + 14) + "px";
|
||
}
|
||
function hideTip() { tip.hidden = true; }
|
||
|
||
function linkTip(b, map) {
|
||
const n = (g) => { const s = map.switches.find((x) => x.guid === g); const h = map.hosts.find((x) => x.guid === g);
|
||
return s ? s.name : h ? (h.node ? h.node + " " + (h.hca || "") : h.desc) : g; };
|
||
const k = b.links[0];
|
||
const exp = k.host ? map.expected_host_gbps : map.expected_switch_gbps;
|
||
return `<b>${esc(n(b.from))} ↔ ${esc(n(b.to))}</b><br>${b.links.length > 1 ? b.links.length + " cables, " : ""}${esc(k.rate)} · ${k.gbps} Gb/s` +
|
||
(b.links.some((x) => x.degraded) ? `<br><span class="fm-bad">trained below the ${exp} Gb/s the rest of its kind run at</span>` : "");
|
||
}
|
||
function switchTip(s, map, touches) {
|
||
const bad = touches[s.guid] && touches[s.guid].bad;
|
||
return `<b>${esc(s.name)}</b><br>${s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf"} · ${s.used}/${s.ports} ports` +
|
||
(s.hosts ? ` · ${s.hosts} adapters` : "") + (s.lid ? ` · LID ${s.lid}` : "") + (bad ? `<br><span class="fm-bad">${bad} degraded cable(s)</span>` : "");
|
||
}
|
||
function hostTip(h, live) {
|
||
let t = `<b>${esc(h.node || h.desc || h.guid)}</b>${h.hca ? " · " + esc(h.hca) : ""}<br>rail ${h.rail}`;
|
||
if (live) {
|
||
t += ` · ${esc(live.state)}${live.gbps ? " · " + live.gbps + " Gb/s" : ""}`;
|
||
if (live.tx_bps || live.rx_bps) t += `<br>↑ ${fmtBps(live.tx_bps)} · ↓ ${fmtBps(live.rx_bps)}`;
|
||
if (live.errors) t += `<br><span class="fm-bad">${Object.entries(live.errors).map(([k, v]) => esc(k) + " " + v).join(", ")}</span>`;
|
||
} else if (!h.node) {
|
||
t += "<br><span class=\"fm-dim\">not one of this cluster's nodes</span>";
|
||
}
|
||
return t;
|
||
}
|
||
function switchPanel(s, map) {
|
||
const cables = map.links.filter((k) => k.from === s.guid || k.to === s.guid);
|
||
const bad = cables.filter((k) => k.degraded);
|
||
const rows = bad.map((k) => { const o = k.from === s.guid ? k.to : k.from; const on = map.switches.find((x) => x.guid === o) || map.hosts.find((x) => x.guid === o);
|
||
return `<li>port ${k.from === s.guid ? k.from_port : k.to_port} → ${esc(on ? (on.name || on.node || on.desc) : o)}: ${esc(k.rate)}</li>`; }).join("");
|
||
return `<button class="fm-close" aria-label="close">×</button><h4>${esc(s.name)}</h4>
|
||
<dl><dt>tier</dt><dd>${s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf"}</dd><dt>GUID</dt><dd><code>${esc(s.guid)}</code></dd>
|
||
<dt>LID</dt><dd>${s.lid || "—"}</dd><dt>ports</dt><dd>${s.used} of ${s.ports} cabled</dd><dt>adapters</dt><dd>${s.hosts}</dd></dl>` +
|
||
(bad.length ? `<p class="fm-bad">Degraded cables</p><ul>${rows}</ul>` : "<p class=\"fm-dim\">Every cable at its expected rate.</p>");
|
||
}
|
||
function hostPanel(h, live, map) {
|
||
const leaf = map.switches.find((x) => x.guid === h.leaf);
|
||
let body = `<dl><dt>adapter</dt><dd>${esc(h.hca || h.desc)}</dd><dt>GUID</dt><dd><code>${esc(h.guid)}</code></dd>
|
||
<dt>rail</dt><dd>${h.rail}</dd><dt>leaf</dt><dd>${esc(leaf ? leaf.name : "—")}</dd>`;
|
||
if (live) {
|
||
body += `<dt>state</dt><dd>${esc(live.state)} (${esc(live.phys_state)})</dd><dt>rate</dt><dd>${live.gbps} Gb/s</dd>
|
||
<dt>sending</dt><dd>${fmtBps(live.tx_bps)}</dd><dt>receiving</dt><dd>${fmtBps(live.rx_bps)}</dd>`;
|
||
}
|
||
body += "</dl>";
|
||
if (live && live.errors) body += `<p class="fm-bad">${Object.entries(live.errors).map(([k, v]) => esc(k) + " " + v).join("<br>")}</p>`;
|
||
const link = h.node ? `<a href="${esc(root.dataset.nodes + "/" + encodeURIComponent(h.node))}">open ${esc(h.node)} →</a>` : "";
|
||
return `<button class="fm-close" aria-label="close">×</button><h4>${esc(h.node || h.desc || "adapter")}</h4>${body}${link}`;
|
||
}
|
||
panel.addEventListener("click", (e) => {
|
||
if (e.target.classList.contains("fm-close")) { panel.hidden = true; state.selected = null; unhighlight(); }
|
||
});
|
||
canvas.addEventListener("click", () => { panel.hidden = true; state.selected = null; unhighlight(); });
|
||
|
||
function matches(h) {
|
||
const q = state.query.toLowerCase();
|
||
return (h.node || "").toLowerCase().includes(q) || (h.desc || "").toLowerCase().includes(q) || (h.hca || "").toLowerCase().includes(q);
|
||
}
|
||
|
||
// --- pan and zoom ------------------------------------------------------
|
||
function applyView() {
|
||
if (viewport) viewport.setAttribute("transform", `translate(${state.view.x},${state.view.y}) scale(${state.view.k})`);
|
||
}
|
||
function enablePanZoom(s) {
|
||
let drag = null;
|
||
s.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const r = s.getBoundingClientRect();
|
||
const scale = s.viewBox.baseVal.width / r.width;
|
||
const mx = (e.clientX - r.left) * scale, my = (e.clientY - r.top) * scale;
|
||
const k = Math.min(4, Math.max(0.25, state.view.k * (e.deltaY < 0 ? 1.12 : 1 / 1.12)));
|
||
state.view.x = mx - (mx - state.view.x) * (k / state.view.k);
|
||
state.view.y = my - (my - state.view.y) * (k / state.view.k);
|
||
state.view.k = k;
|
||
applyView();
|
||
}, { passive: false });
|
||
s.addEventListener("pointerdown", (e) => { if (e.target.closest(".fm-nodes > g")) return; drag = { x: e.clientX, y: e.clientY, vx: state.view.x, vy: state.view.y }; s.setPointerCapture(e.pointerId); s.classList.add("dragging"); });
|
||
s.addEventListener("pointermove", (e) => {
|
||
if (!drag) return;
|
||
const r = s.getBoundingClientRect();
|
||
const scale = s.viewBox.baseVal.width / r.width;
|
||
state.view.x = drag.vx + (e.clientX - drag.x) * scale;
|
||
state.view.y = drag.vy + (e.clientY - drag.y) * scale;
|
||
applyView();
|
||
});
|
||
s.addEventListener("pointerup", () => { drag = null; s.classList.remove("dragging"); });
|
||
s.addEventListener("dblclick", () => { state.view = { x: 0, y: 0, k: 1 }; applyView(); });
|
||
}
|
||
|
||
// --- the strip above ----------------------------------------------------
|
||
function fillStats() {
|
||
if (!stats) return;
|
||
const m = data && data.map;
|
||
const live = data && data.live ? Object.values(data.live) : [];
|
||
const tx = live.reduce((a, l) => a + (l.tx_bps || 0), 0);
|
||
const cells = [];
|
||
if (m) {
|
||
cells.push([m.switches.length, "switches"], [m.hosts.length, "adapters"], [m.links.length, "cables"],
|
||
[m.degraded, "degraded", m.degraded ? "bad" : "ok"],
|
||
[m.rails.length > 1 ? (m.rail_aligned ? "aligned" : "crossed") : "—", "rails", m.rails.length > 1 && !m.rail_aligned ? "bad" : ""]);
|
||
}
|
||
cells.push([fmtBps(tx), "sent now"]);
|
||
stats.innerHTML = cells.map(([v, k, c]) => `<div class="fm-stat ${c || ""}"><b>${esc(v)}</b><span>${esc(k)}</span></div>`).join("");
|
||
}
|
||
|
||
// --- toolbar -------------------------------------------------------------
|
||
root.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("on", b.dataset.mode === state.mode));
|
||
root.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => {
|
||
state.mode = b.dataset.mode;
|
||
history.replaceState(null, "", state.mode === "rails" ? "#rails" : location.pathname);
|
||
root.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x === b));
|
||
draw();
|
||
}));
|
||
const tbtn = root.querySelector("[data-toggle=traffic]");
|
||
if (tbtn) { tbtn.classList.toggle("on", state.traffic); tbtn.addEventListener("click", () => { state.traffic = !state.traffic; tbtn.classList.toggle("on", state.traffic); draw(); }); }
|
||
const pbtn = root.querySelector("[data-toggle=problems]");
|
||
if (pbtn) pbtn.addEventListener("click", () => { state.problems = !state.problems; pbtn.classList.toggle("on", state.problems); draw(); });
|
||
const search = root.querySelector(".fm-search");
|
||
if (search) search.addEventListener("input", () => { state.query = search.value.trim(); draw(); });
|
||
const reset = root.querySelector("[data-reset]");
|
||
if (reset) reset.addEventListener("click", () => { state.view = { x: 0, y: 0, k: 1 }; applyView(); });
|
||
|
||
draw();
|
||
window.addEventListener("resize", () => { clearTimeout(draw._t); draw._t = setTimeout(draw, 150); });
|
||
|
||
// Live: adapters' state and traffic every fifteen seconds, the map itself
|
||
// when a new scan has made one.
|
||
if (url) setInterval(async () => {
|
||
if (document.visibilityState !== "visible") return;
|
||
try {
|
||
const r = await fetch(url, { headers: { Accept: "application/json" } });
|
||
if (!r.ok) return;
|
||
data = await r.json();
|
||
draw();
|
||
} catch (_) { /* the next one */ }
|
||
}, 15000);
|
||
})();
|