Files
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
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>
2026-10-05 00:12:53 +02:00

476 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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);
})();