Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
476 lines
26 KiB
JavaScript
476 lines
26 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");
|
||
|
||
// --- words -------------------------------------------------------------
|
||
// What the map says comes from the page, in the page's language: the
|
||
// server writes its catalogue's sentences into #fabric-i18n, with {0}, {1}
|
||
// where a figure goes. English is kept here for a page that has none, so
|
||
// the map still speaks rather than printing keys.
|
||
const EN = {
|
||
decimal: ".",
|
||
map_failed: "The map could not be made: {0}",
|
||
not_mapped: "Not mapped yet: the first scan runs within five minutes of a node on this fabric connecting.",
|
||
"tier.leaf": "leaf", "tier.spine": "spine", "tier.core": "core", "tier.supercore": "super-core", "tier.n": "tier {0}",
|
||
adapters_row: "adapters", ports_used: "{0}/{1} ports", hosts: "{0} hosts", cables_n: "{0} cables, ",
|
||
below_rate: "trained below the {0} Gb/s the rest of its kind run at",
|
||
adapters_n: "{0} adapters", degraded_n: "{0} degraded cable(s)", rail: "rail {0}", not_ours: "not one of this cluster's nodes",
|
||
"dt.tier": "tier", "dt.ports": "ports", "dt.adapters": "adapters", "dt.adapter": "adapter", "dt.rail": "rail",
|
||
"dt.leaf": "leaf", "dt.state": "state", "dt.rate": "rate", "dt.sending": "sending", "dt.receiving": "receiving",
|
||
cabled: "{0} of {1} cabled", degraded_cables: "Degraded cables", all_good: "Every cable at its expected rate.",
|
||
port_to: "port {0} → {1}: {2}", close: "close", open_node: "open {0} →", adapter: "adapter",
|
||
"stat.switches": "switches", "stat.adapters": "adapters", "stat.cables": "cables", "stat.degraded": "degraded",
|
||
"stat.rails": "rails", "stat.aligned": "aligned", "stat.crossed": "crossed", "stat.sent": "sent now",
|
||
};
|
||
const S = (() => {
|
||
const e = document.getElementById("fabric-i18n");
|
||
try { return e ? JSON.parse(e.textContent) : {}; } catch (_) { return {}; }
|
||
})();
|
||
const T = (k, ...a) => String(S[k] || EN[k] || k).replace(/\{(\d)\}/g, (_, i) => a[+i]);
|
||
const fixed = (v, n) => v.toFixed(n).replace(".", S.decimal || EN.decimal);
|
||
|
||
// --- 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 fixed(bits / 1e9, 1) + " Gb/s";
|
||
if (bits >= 1e6) return fixed(bits / 1e6, 1) + " Mb/s";
|
||
if (bits >= 1e3) return fixed(bits / 1e3, 0) + " kb/s";
|
||
return fixed(bits, 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 ? T("map_failed", data.error) : T("not_mapped");
|
||
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: T("tier.leaf"), 2: T("tier.spine"), 3: T("tier.core"), 4: T("tier.supercore") };
|
||
// 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] || T("tier.n", t));
|
||
label(L.rowY(1) + L.gap, T("adapters_row"));
|
||
|
||
// 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 tierClass = s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf";
|
||
const g = el("g", { class: "fm-switch t-" + tierClass + (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-${tierClass})` }, 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 = T("ports_used", s.used, s.ports) + (s.hosts ? " · " + T("hosts", s.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 ? esc(T("cables_n", b.links.length)) : ""}${esc(k.rate)} · ${k.gbps} Gb/s` +
|
||
(b.links.some((x) => x.degraded) ? `<br><span class="fm-bad">${esc(T("below_rate", exp))}</span>` : "");
|
||
}
|
||
const tierName = (s) => s.tier >= 3 ? T("tier.core") : s.tier === 2 ? T("tier.spine") : T("tier.leaf");
|
||
function switchTip(s, map, touches) {
|
||
const bad = touches[s.guid] && touches[s.guid].bad;
|
||
return `<b>${esc(s.name)}</b><br>${esc(tierName(s))} · ${esc(T("ports_used", s.used, s.ports))}` +
|
||
(s.hosts ? " · " + esc(T("adapters_n", s.hosts)) : "") + (s.lid ? ` · LID ${s.lid}` : "") +
|
||
(bad ? `<br><span class="fm-bad">${esc(T("degraded_n", bad))}</span>` : "");
|
||
}
|
||
function hostTip(h, live) {
|
||
let t = `<b>${esc(h.node || h.desc || h.guid)}</b>${h.hca ? " · " + esc(h.hca) : ""}<br>${esc(T("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">${esc(T("not_ours"))}</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>${esc(T("port_to", k.from === s.guid ? k.from_port : k.to_port, on ? (on.name || on.node || on.desc) : o, k.rate))}</li>`; }).join("");
|
||
return `<button class="fm-close" aria-label="${esc(T("close"))}">×</button><h4>${esc(s.name)}</h4>
|
||
<dl><dt>${esc(T("dt.tier"))}</dt><dd>${esc(tierName(s))}</dd><dt>GUID</dt><dd><code>${esc(s.guid)}</code></dd>
|
||
<dt>LID</dt><dd>${s.lid || "—"}</dd><dt>${esc(T("dt.ports"))}</dt><dd>${esc(T("cabled", s.used, s.ports))}</dd><dt>${esc(T("dt.adapters"))}</dt><dd>${s.hosts}</dd></dl>` +
|
||
(bad.length ? `<p class="fm-bad">${esc(T("degraded_cables"))}</p><ul>${rows}</ul>` : `<p class="fm-dim">${esc(T("all_good"))}</p>`);
|
||
}
|
||
function hostPanel(h, live, map) {
|
||
const leaf = map.switches.find((x) => x.guid === h.leaf);
|
||
let body = `<dl><dt>${esc(T("dt.adapter"))}</dt><dd>${esc(h.hca || h.desc)}</dd><dt>GUID</dt><dd><code>${esc(h.guid)}</code></dd>
|
||
<dt>${esc(T("dt.rail"))}</dt><dd>${h.rail}</dd><dt>${esc(T("dt.leaf"))}</dt><dd>${esc(leaf ? leaf.name : "—")}</dd>`;
|
||
if (live) {
|
||
body += `<dt>${esc(T("dt.state"))}</dt><dd>${esc(live.state)} (${esc(live.phys_state)})</dd><dt>${esc(T("dt.rate"))}</dt><dd>${live.gbps} Gb/s</dd>
|
||
<dt>${esc(T("dt.sending"))}</dt><dd>${fmtBps(live.tx_bps)}</dd><dt>${esc(T("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))}">${esc(T("open_node", h.node))}</a>` : "";
|
||
return `<button class="fm-close" aria-label="${esc(T("close"))}">×</button><h4>${esc(h.node || h.desc || T("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, T("stat.switches")], [m.hosts.length, T("stat.adapters")], [m.links.length, T("stat.cables")],
|
||
[m.degraded, T("stat.degraded"), m.degraded ? "bad" : "ok"],
|
||
[m.rails.length > 1 ? (m.rail_aligned ? T("stat.aligned") : T("stat.crossed")) : "—", T("stat.rails"), m.rails.length > 1 && !m.rail_aligned ? "bad" : ""]);
|
||
}
|
||
cells.push([fmtBps(tx), T("stat.sent")]);
|
||
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);
|
||
})();
|