Files
gandalf/static/topology.js
T
jaredandClaude Opus 5.5 7dba1750ee
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 1m16s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m42s
Lint / Python (flake8) (pull_request) Successful in 1m41s
Test / Python Tests (pytest) (push) Successful in 2m28s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m22s
Test / Python Tests (pytest) (pull_request) Successful in 1m37s
Topology: a readable card per device
Drawn to true scale, the 4U servers dwarfed the switches and no label
could be read without zooming. Every device is now a card of the same
height: its name and state on top, and below that its real faceplate
(cropped to the ports) or, for a server, its front and a socket per NIC.
Cables meet the card edge right at the port, with a short tail to it.

The internet, modem and gateway sit side by side along the top, and
unknown devices share the row of the gear on their switch, so the whole
network fits a normal screen. On a phone the canvas takes the diagram's
shape instead of leaving most of it empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 19:58:29 -05:00

1095 lines
48 KiB
JavaScript

'use strict';
/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */
// Topology page: a card per device with its real faceplate or chassis, and each cable leaving the
// exact port it is plugged into. Data comes from /api/topology (topology.build_graph).
const esc = s => lt.escHtml(s == null ? '' : String(s));
const PER_MM = 1100 / 482.6;
const NODE_GAP = 90;
const REFRESH_MS = 30000;
const FILTER_KEY = 'gandalf.topology.filters';
const DEFAULT_FILTERS = { stubs: 'grouped', power: true, traffic: true, tenG: false, alerts: false };
const STUB_MODES = ['grouped', 'all', 'none'];
const state = {
data: null,
layout: null,
view: { k: 1, tx: 0, ty: 0 },
fitted: false,
filters: loadFilters(),
expanded: new Set(), // switches whose unknown devices are shown one by one
selected: null,
};
function loadFilters() {
try {
const saved = Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'));
if (!STUB_MODES.includes(saved.stubs)) saved.stubs = saved.stubs === false ? 'none' : 'grouped';
return saved;
} catch (_) {
return Object.assign({}, DEFAULT_FILTERS);
}
}
function saveFilters() {
try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.filters)); } catch (_) { /* private mode */ }
}
// ── Formatting ──────────────────────────────────────────────────────────
function speedText(mbps) {
if (!mbps) return '';
return mbps >= 1000 ? `${+(mbps / 1000).toFixed(1)}G` : `${mbps}M`;
}
function rateText(bytesPerSec) {
const bits = (bytesPerSec || 0) * 8;
if (bits >= 1e9) return `${(bits / 1e9).toFixed(2)} 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 MEDIUM_TEXT = { dac: 'DAC (twinax)', fibre: 'Fibre', sfp: 'SFP+ module', copper: 'Copper' };
function deviceHref(name) {
return `/device/${encodeURIComponent(name)}`;
}
function nodeHref(node) {
if (node.kind === 'group') return '';
if (node.kind === 'stub') return `${deviceHref(node.switch)}#port-${encodeURIComponent(node.port_idx)}`;
if (node.kind === 'internet') return '';
return deviceHref(node.name);
}
function speedClass(mbps) {
if (mbps >= 10000) return 'tp-s-10g';
if (mbps >= 1000) return 'tp-s-1g';
return 'tp-s-100m';
}
function mediumClass(link) {
if (link.wan && link.medium === 'fibre') return 'tp-m-wanfibre';
return `tp-m-${link.medium || 'copper'}`;
}
// ── Device drawings ─────────────────────────────────────────────────────
// The shared drawings size themselves to their page with CSS (width: 100%), which would override
// the width and height given here, so they are drawn under a class of our own.
function nested(svg) {
return svg.replace(/^(\s*<svg class=")(fp-svg|rack-svg rack-front)"/, '$1tp-inner"');
}
// Each returns { w, h, svg: markup at (0,0), anchor(end) -> {x, y, face} | null } in local units.
function portMapOf(node) {
const map = {};
Object.entries(node.ports || {}).forEach(([idx, p]) => {
map[idx] = Object.assign({ port_idx: Number(idx), lldp: { system_name: p.neighbour } }, p);
});
return map;
}
function portTitle(port, idx, role) {
const parts = [port && port.name && port.name !== `Port ${idx}` ? `Port ${idx} "${port.name}"` : `Port ${idx}`];
if (role) parts.push(role);
if (port) {
parts.push(port.up ? (speedText(port.speed_mbps) || 'up') : 'down');
if (port.neighbour) parts.push(`to ${port.neighbour}`);
}
return esc(parts.join(' · '));
}
function faceplateDrawing(node, model, width, opts = {}) {
const spec = FACEPLATES[model];
let box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height];
if (opts.crop && spec.ports.length > 2) {
// Just the port area, so the ports are big enough to read in a card
const left = spec.ports.map(q => (q.type === 'sfp' ? q.x : q.x - 20 * (q.scale || 1)));
const right = spec.ports.map(q => (q.type === 'sfp' ? q.x + q.w : q.x + 20 * (q.scale || 1)));
const x0 = Math.max(0, Math.min(...left) - 24);
const x1 = Math.min(spec.width, Math.max(...right) + 24);
box = [x0, 0, x1 - x0, spec.height];
}
const k = width / box[2];
const h = box[3] * k;
// A rack view (the UPS's front without its rear) is a viewBox onto part of the drawing
const svg = faceplateSvg(model, {
swName: node.name, portMap: portMapOf(node), outlets: node.outlets || [], online: node.status === 'up',
rack: !!opts.rack, titleFor: portTitle,
}, `x="${opts.offsetX || 0}" y="0" width="${width}" height="${h}"`).svg;
const drawn = nested(svg).replace(/viewBox="[^"]*"/, `viewBox="${box.join(' ')}"`);
return {
w: width, h, svg: drawn,
anchor(end) {
if (end.port_idx == null) return null;
const a = faceplatePortAnchor(spec, end.port_idx);
return a && { x: (opts.offsetX || 0) + (a.x - box[0]) * k, y: (a.y - box[1]) * k, face: a.face };
},
};
}
function deskFaceplate(node) {
const desk = DESK_FACEPLATES[node.model];
const spec = FACEPLATES[node.model];
const k = (desk.mm * PER_MM) / (desk.body[1] - desk.body[0]);
return faceplateDrawing(node, node.model, spec.width * k);
}
function genericSwitch(node) {
const ports = Object.keys(node.ports || {}).map(Number).sort((a, b) => a - b);
const w = 1100, h = 110;
const pitch = Math.min(60, 900 / Math.max(ports.length, 1));
const x0 = w - 60 - pitch * ports.length;
const pos = {};
const jacks = ports.map((idx, i) => {
const p = node.ports[idx];
const x = x0 + i * pitch;
pos[idx] = x + pitch / 2;
return `<g><title>${portTitle(p, idx)}</title><rect class="tp-gen-port${p.up ? ' tp-gen-up' : ''}" x="${x + 4}" y="38" width="${pitch - 8}" height="34" rx="3"/>
<text class="tp-gen-num" x="${x + pitch / 2}" y="92" text-anchor="middle">${idx}</text></g>`;
}).join('');
return {
w, h,
svg: `<rect class="tp-gen-body" width="${w}" height="${h}" rx="6"/>
<text class="tp-gen-model" x="40" y="62">${esc(node.model || 'switch')}</text>${jacks}`,
anchor: end => (end.port_idx != null && pos[end.port_idx] != null ? { x: pos[end.port_idx], y: 55, face: 'top' } : null),
};
}
function internetDrawing() {
const w = 560, h = 250;
return {
w, h,
svg: `<path class="tp-cloud" d="M120 230 C40 230 20 150 90 128 C80 60 170 30 220 74 C250 14 370 10 400 84 C470 60 540 110 500 168 C550 200 520 236 450 230 Z"/>
<text class="tp-cloud-text" x="280" y="168" text-anchor="middle">INTERNET</text>`,
anchor: () => ({ x: w / 2, y: h - 22, face: 'bottom' }),
};
}
function modemDrawing(node) {
// An upright fibre gateway seen from the front, its status lights down the face
const w = 170, h = 520;
const leds = ['Power', 'Broadband', 'Service', 'Ethernet', 'Wi-Fi'].map((name, i) => {
const cls = node.status === 'up' ? 'tp-led-on' : (i === 0 ? 'tp-led-on' : (node.status === 'down' ? 'tp-led-bad' : 'tp-led-off'));
return `<circle class="tp-led ${cls}" cx="${w / 2}" cy="${230 + i * 46}" r="7"><title>${name}</title></circle>`;
}).join('');
return {
w, h,
svg: `<rect class="tp-modem-body" width="${w}" height="${h}" rx="26"/>
<rect class="tp-modem-edge" x="8" y="8" width="${w - 16}" height="${h - 16}" rx="20"/>
<circle class="tp-modem-globe" cx="${w / 2}" cy="110" r="38"/>
<path class="tp-modem-globe-line" d="M${w / 2 - 38} 110 h76 M${w / 2} 72 v76 M${w / 2 - 30} 90 h60 M${w / 2 - 30} 130 h60"/>
${leds}
<text class="tp-modem-model" x="${w / 2}" y="${h - 34}" text-anchor="middle">${esc((node.model || '').split('-')[0])}</text>`,
// Fibre comes in at the top, the LAN cable leaves at the bottom
anchor: end => (end.port_name === 'WAN' ? { x: w / 2, y: h - 8, face: 'bottom' } : { x: w / 2, y: 10, face: 'top' }),
};
}
function apDrawing(node) {
const d = 160 * PER_MM;
const r = d / 2;
return {
w: d, h: d,
svg: `<circle class="tp-ap-body" cx="${r}" cy="${r}" r="${r - 2}"/>
<circle class="tp-ap-ring tp-ap-${esc(node.status)}" cx="${r}" cy="${r}" r="${r * 0.42}"/>
<circle class="tp-ap-dot" cx="${r}" cy="${r}" r="${r * 0.12}"/>`,
anchor: () => ({ x: r, y: 6, face: 'top' }),
};
}
function stripDrawing(node) {
const w = 700, h = 140;
const outlets = node.outlets || [];
const n = Math.max(outlets.length, 6);
const sockets = Array.from({ length: n }, (_, i) => {
const o = outlets[i];
const off = o && o.on === false;
const title = o ? `${o.name || `Outlet ${i + 1}`} · ${off ? 'off' : 'on'}${o.power_w != null ? ` · ${o.power_w.toFixed(1)} W` : ''}` : `Outlet ${i + 1}`;
return `<g class="tp-strip-outlet${off ? ' tp-off' : ''}"><title>${esc(title)}</title>
<rect x="${70 + i * 95}" y="34" width="72" height="72" rx="14"/>
<rect class="tp-strip-slot" x="${88 + i * 95}" y="56" width="6" height="22" rx="2"/>
<rect class="tp-strip-slot" x="${118 + i * 95}" y="56" width="6" height="22" rx="2"/></g>`;
}).join('');
return {
w: Math.max(w, 90 + n * 95), h,
svg: `<rect class="tp-strip-body" width="${Math.max(w, 90 + n * 95)}" height="${h}" rx="30"/>${sockets}`,
anchor: () => null,
};
}
function groupDrawing(node) {
const n = node.members.length;
const cols = Math.min(n, 7);
const w = Math.max(360, cols * 64 + 60), h = 150;
const jacks = node.members.slice(0, 14).map((m, i) => {
const x = 30 + (i % cols) * 64, y = 22 + Math.floor(i / cols) * 46;
return `<path class="tp-stub-jack${m.status === 'up' ? '' : ' tp-off'}" d="M${x + 8} ${y + 10} h12 v-6 h16 v6 h12 v26 h-40 Z"/>`;
}).join('');
return {
w, h,
svg: `<rect class="tp-stub-body tp-group-body" width="${w}" height="${h}" rx="12"/>${jacks}
<text class="tp-group-hint" x="${w / 2}" y="${h - 14}" text-anchor="middle">click to show</text>`,
anchor: () => ({ x: w / 2, y: 4, face: 'top' }),
};
}
function boxDrawing(node, text) {
const w = 460, h = 150;
return {
w, h,
svg: `<rect class="tp-box" width="${w}" height="${h}" rx="10"/>
<text class="tp-box-text" x="${w / 2}" y="${h / 2 + 12}" text-anchor="middle">${esc(text)}</text>`,
anchor: () => null,
};
}
function stubDrawing() {
const w = 160, h = 100;
return {
w, h,
svg: `<rect class="tp-stub-body" x="20" y="8" width="120" height="84" rx="10"/>
<path class="tp-stub-jack" d="M48 34 h20 v-8 h24 v8 h20 v40 h-64 Z"/>
<text class="tp-stub-q" x="80" y="64" text-anchor="middle">?</text>`,
anchor: () => ({ x: w / 2, y: 10, face: 'top' }),
};
}
// A host's drawn front, from its cutsheet item's Front drawing
function hostFront(node) {
const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] };
if (RACK_FRONTS[node.chassis]) {
const spec = RACK_FRONTS[node.chassis];
return { w: spec.width, h: spec.height,
svg: nested(rackFrontSvg(node.chassis, ctx)).replace('<svg ', `<svg width="${spec.width}" height="${spec.height}" `) };
}
if (SHELF_GEAR[node.chassis]) {
const gear = SHELF_GEAR[node.chassis];
return { w: gear.width, h: gear.height,
svg: `<svg width="${gear.width}" height="${gear.height}" viewBox="0 0 ${gear.width} ${gear.height}">${gear.draw(ctx)}</svg>` };
}
if (CHASSIS_FACEPLATES[node.chassis]) return faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100);
return null;
}
// The picture inside a card, for everything but hosts
function artFor(node) {
const spec = FACEPLATES[node.model];
switch (node.kind) {
case 'internet': return internetDrawing();
case 'modem': return modemDrawing(node);
case 'stub': return stubDrawing();
case 'group': return groupDrawing(node);
case 'ap': return apDrawing(node);
case 'strip': return stripDrawing(node);
default:
if (spec && DESK_FACEPLATES[node.model]) return deskFaceplate(node);
if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups', crop: !node.power });
if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node);
return boxDrawing(node, node.model || node.kind);
}
}
// ── Cards ───────────────────────────────────────────────────────────────
// Every device is a card of the same height: its name and state on top, and below that its real
// faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card
// edge right above or below the port they are plugged into.
const CARD_W = 640;
const CARD_SMALL_W = 460;
const CARD_H = 300;
const BAND_Y = 132;
const BAND_H = 140;
const NIC_W = 104;
function fitArt(art, x, y, w, h) {
const k = Math.min(w / art.w, h / art.h);
return { k, ox: x + (w - art.w * k) / 2, oy: y + (h - art.h * k) / 2 };
}
function nicSockets(node, linksByIface, x0) {
const nics = (node.interfaces || []).slice();
Object.keys(linksByIface).forEach(iface => {
if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' });
});
const pos = {};
const svg = nics.map((nic, i) => {
const link = linksByIface[nic.name];
const cx = x0 + i * NIC_W + NIC_W / 2;
const cy = BAND_Y + 40;
pos[nic.name] = { x: cx, y: cy - 20 };
const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium);
let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down');
if (link && !link.up) st = 'down';
const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`;
const shape = sfp
? `<rect class="tp-nic-sfp" x="${cx - 30}" y="${cy - 20}" width="60" height="38" rx="3"/><rect class="tp-nic-sfp-in" x="${cx - 22}" y="${cy - 13}" width="44" height="24" rx="2"/>`
: `<path class="tp-nic-rj45" d="M${cx - 28} ${cy - 12} h16 v-8 h24 v8 h16 v32 h-56 Z"/>`;
const label = nic.name.length > 9 ? `${nic.name.slice(0, 8)}…` : nic.name;
return `<g class="tp-nic tp-nic-${st}${nic.alert ? ` tp-nic-alert-${esc(nic.alert)}` : ''}"><title>${esc(title)}</title>
${shape}<circle class="tp-nic-led" cx="${cx + 22}" cy="${cy + 28}" r="5"/>
<text class="tp-nic-name" x="${cx}" y="${cy + 70}" text-anchor="middle">${esc(label)}</text></g>`;
}).join('');
return { svg, pos, w: nics.length * NIC_W };
}
function cardFor(node, linksByIface) {
let w, band, anchorAt;
if (node.kind === 'host') {
const front = hostFront(node);
const sockets0 = nicSockets(node, linksByIface, 0);
const frontW = front ? 220 : 0;
w = Math.max(CARD_W, 56 + frontW + (front ? 24 : 0) + sockets0.w, Math.min(node.name.length * 27 + 130, 980));
const sx = 28 + (front ? frontW + 24 : (w - 56 - sockets0.w) / 2);
const sockets = nicSockets(node, linksByIface, sx);
let art = '';
if (front) {
const f = fitArt(front, 28, BAND_Y, frontW, BAND_H);
art = `<g transform="translate(${f.ox} ${f.oy}) scale(${f.k})">${front.svg}</g>`;
}
band = `${art}${sockets.svg}`;
anchorAt = end => (end.iface && sockets.pos[end.iface]) || null;
} else {
const small = ['internet', 'modem', 'stub', 'group', 'ap', 'strip'].includes(node.kind);
w = Math.max(small ? CARD_SMALL_W : CARD_W, Math.min(node.name.length * 27 + 130, 980));
const art = artFor(node);
const f = fitArt(art, 28, BAND_Y, w - 56, BAND_H);
band = `<g transform="translate(${f.ox} ${f.oy}) scale(${f.k})">${art.svg}</g>`;
anchorAt = end => {
const a = art.anchor(end);
return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null;
};
}
const maxChars = Math.floor((w - 130) / 27);
const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name;
let sub;
if (node.kind === 'group') sub = `on ${node.switch}`;
else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`);
else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`;
else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status;
else sub = [node.model || node.kind, node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
const svg = `<rect class="tp-card" width="${w}" height="${CARD_H}" rx="18"/>
<rect class="tp-card-stripe tp-stripe-${esc(node.status)}" x="0" y="18" width="8" height="${CARD_H - 36}" rx="4"/>
<text class="tp-card-name" x="34" y="66">${esc(name)}</text>
<text class="tp-card-sub" x="34" y="108">${esc(sub)}</text>
<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${w - 42}" cy="52" r="13"/>
<rect class="tp-band" x="20" y="${BAND_Y - 8}" width="${w - 40}" height="${BAND_H + 16}" rx="10"/>
${band}`;
return { w, h: CARD_H, svg, anchorAt };
}
// ── Layout ──────────────────────────────────────────────────────────────
// Rows top down by hops from the internet. Cables are routed like cable management: each drops
// straight down from its port into the tray above the row it is going to, runs along its own lane
// there, then drops straight into its socket. Lanes never share, so cables never lie on top of one
// another and only ever cross at right angles. Power gear hangs in a rail on the right.
const TRAY_PAD = 70;
const LANE = 30;
const TRAY_MIN = 200;
const CORRIDOR_PAD = 60;
const CORNER = 26;
// Unknown devices on one switch are grouped into a single block with one bundled cable, unless
// the viewer opens that switch's group (or shows them all)
function visibleGraph(data, filters, expanded) {
const mode = filters.stubs;
let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power));
let links = data.links.slice();
if (mode === 'grouped') {
const bySwitch = {};
nodes.filter(n => n.kind === 'stub').forEach(n => (bySwitch[n.switch] = bySwitch[n.switch] || []).push(n));
Object.entries(bySwitch).forEach(([sw, stubs]) => {
if (stubs.length < 2 || expanded.has(sw)) return;
const ids = new Set(stubs.map(n => n.id));
const cables = links.filter(l => ids.has(l.b.node));
const id = `group:${sw}`;
nodes = nodes.filter(n => !ids.has(n.id)).concat([{
id, name: `${stubs.length} unknown devices`, kind: 'group', status: 'up', depth: stubs[0].depth,
switch: sw, members: stubs, alerts: [], alert_count: 0, severity: '', power: false,
}]);
links = links.filter(l => !ids.has(l.b.node)).concat([{
id: `bundle:${sw}`, bundle: true, count: cables.length, ghost: false, wan: false, name_mismatch: null,
a: { node: sw, port_idx: null, port_name: '', iface: '', ports: cables.map(l => l.a.port_idx) },
b: { node: id, port_idx: null, port_name: '', iface: '' },
up: cables.some(l => l.up), medium: 'copper', speed_mbps: Math.max(...cables.map(l => l.speed_mbps || 0)),
a_to_b_bps: cables.reduce((t, l) => t + (l.a_to_b_bps || 0), 0),
b_to_a_bps: cables.reduce((t, l) => t + (l.b_to_a_bps || 0), 0),
}]);
});
}
const ids = new Set(nodes.map(n => n.id));
return { nodes, links: links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)) };
}
// The internet, modem and gateway share the top row, left to right; then a row per hop, with
// unknown devices beside the gear on their switch; servers along the bottom
function rowKey(node) {
if (node.kind === 'host') return 1e6;
if (node.depth == null) return 1e5;
if (['internet', 'modem', 'router'].includes(node.kind) && node.depth <= 2) return 0;
return node.depth;
}
// Where a cable meets a card: on its bottom edge (leaving) or top edge (arriving), right above or
// below the port it is plugged into, which is given as `port` for the short tail drawn to it
function anchorOf(lay, end, asParent) {
const p = lay.pos[end.node];
const d = lay.drawings[end.node];
if (p.rail) return { x: p.x, y: p.y + d.h / 2, face: 'left', port: null };
let a = null;
if (end.ports) {
// A bundle leaves from the middle of the ports it gathers
const xs = end.ports.map(idx => d.anchorAt({ port_idx: idx })).filter(Boolean);
if (xs.length) a = { x: xs.reduce((t, q) => t + q.x, 0) / xs.length, y: Math.max(...xs.map(q => q.y)) };
} else {
a = d.anchorAt(end);
}
const x = p.x + (a ? a.x : d.w / 2);
return { x, y: p.y + (asParent ? d.h : 0), face: asParent ? 'bottom' : 'top',
port: a ? { x, y: p.y + a.y } : null };
}
// Give each horizontal run in a tray a lane: fastest cables on the upper lanes, and two runs
// share a lane only when they don't overlap
function assignLanes(runs) {
const lanes = [];
runs.sort((a, b) => (b.speed - a.speed) || (a.x0 - b.x0));
runs.forEach(run => {
let i = lanes.findIndex(lane => lane.every(o => run.x1 + 40 < o.x0 || run.x0 > o.x1 + 40));
if (i < 0) {
lanes.push([]);
i = lanes.length - 1;
}
lanes[i].push(run);
run.lane = i;
});
return lanes.length;
}
// Straight runs with rounded corners
function roundedPath(points) {
const pts = points.filter((p, i) => i === 0 || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 0.5);
let d = `M${pts[0].x} ${pts[0].y}`;
for (let i = 1; i < pts.length - 1; i++) {
const [p, c, n] = [pts[i - 1], pts[i], pts[i + 1]];
const r = Math.min(CORNER, Math.hypot(c.x - p.x, c.y - p.y) / 2, Math.hypot(n.x - c.x, n.y - c.y) / 2);
const u = (q, s) => ({ x: c.x + (q.x - c.x) / Math.hypot(q.x - c.x, q.y - c.y) * s, y: c.y + (q.y - c.y) / Math.hypot(q.x - c.x, q.y - c.y) * s });
const a = u(p, r), b = u(n, r);
d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}`;
}
const last = pts[pts.length - 1];
return `${d} L${last.x} ${last.y}`;
}
function layout(data, filters, expanded) {
const { nodes, links } = visibleGraph(data, filters, expanded);
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const drawings = {};
const sizes = {};
nodes.forEach(n => {
const byIface = {};
links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; }));
const d = cardFor(n, byIface);
drawings[n.id] = d;
sizes[n.id] = { w: d.w, h: d.h };
});
const lay = { pos: {}, drawings, nodes, links, byId, routes: {} };
const rowsMap = new Map();
nodes.filter(n => !n.power).forEach(n => {
const key = rowKey(n);
if (!rowsMap.has(key)) rowsMap.set(key, []);
rowsMap.get(key).push(n);
});
const rows = [...rowsMap.keys()].sort((a, b) => a - b).map(k => rowsMap.get(k));
const rowOf = {};
rows.forEach((row, r) => row.forEach(n => { rowOf[n.id] = r; }));
const ax = end => anchorOf(lay, end, true).x;
// ── Across: each row in order of the ports feeding it, clear of cables passing down through it
rows.forEach((row, r) => {
const corridors = links
.filter(l => rowOf[l.a.node] < r && rowOf[l.b.node] > r && lay.pos[l.a.node])
.map(l => ax(l.a)).sort((a, b) => a - b);
const items = row.map(n => {
// Line a device up under its fastest cable, so that one drops straight in
const feeds = links.filter(l => l.b.node === n.id && lay.pos[l.a.node])
.sort((a, b) => (b.speed_mbps || 0) - (a.speed_mbps || 0));
const xs = feeds.length ? [ax(feeds[0].a)] : [];
return { n, w: sizes[n.id].w, bary: xs.length ? xs[0] : null };
});
let last = 0;
items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; });
if (r === 0) items.forEach(it => { it.bary = (it.n.depth || 0) * 1000; });
items.sort((a, b) => a.bary - b.bary || a.n.name.localeCompare(b.n.name));
let right = -Infinity;
items.forEach(it => {
it.x = Math.max(it.bary - it.w / 2, right + NODE_GAP);
right = it.x + it.w;
});
const shift = items.reduce((t, it) => t + (it.bary - (it.x + it.w / 2)), 0) / items.length;
right = -Infinity;
items.forEach(it => {
let x = Math.max(it.x + shift, right + NODE_GAP);
let moved = true;
while (moved) {
moved = false;
for (const c of corridors) {
if (c > x - CORRIDOR_PAD && c < x + it.w + CORRIDOR_PAD) {
x = c + CORRIDOR_PAD;
moved = true;
}
}
}
lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: 0 };
right = x + it.w;
});
});
// ── Trays: the runs in each one, and their lanes
const placed = Object.values(lay.pos);
const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0;
const railNodes = nodes.filter(n => n.power);
const gutter0 = maxX + 160;
const trays = Array.from({ length: rows.length + 1 }, () => []);
const plan = {};
let gutterLane = 0;
links.forEach(l => {
if (!lay.pos[l.a.node]) return;
const xa = ax(l.a);
const toRail = byId[l.b.node].power;
if (!toRail && rowOf[l.a.node] === rowOf[l.b.node]) {
plan[l.id] = { across: true };
return;
}
let r, xb;
if (toRail) {
r = Math.min(rowOf[l.a.node] + 1, rows.length);
xb = gutter0 + (gutterLane++) * LANE;
} else {
if (rowOf[l.b.node] == null) return;
r = rowOf[l.b.node];
xb = anchorOf(lay, l.b, false).x;
}
const run = { id: l.id, x0: Math.min(xa, xb), x1: Math.max(xa, xb), speed: l.speed_mbps || 0, straight: Math.abs(xa - xb) < 2 };
plan[l.id] = { r, xa, xb, toRail, run };
if (!run.straight) trays[r].push(run);
});
const trayH = trays.map(runs => Math.max(TRAY_MIN, TRAY_PAD * 2 + assignLanes(runs) * LANE));
// ── Down: rows and trays stacked
const rowTop = [];
let y = 0;
rows.forEach((row, r) => {
if (r > 0) y += trayH[r];
rowTop[r] = y;
row.forEach(n => { lay.pos[n.id].y = y; });
y += Math.max(...row.map(n => sizes[n.id].h));
});
const trayTop = r => (r < rows.length ? rowTop[r] - trayH[r] : y);
// Power rail: each unit level with the lane its cable arrives on
const railX = gutter0 + Math.max(gutterLane, 1) * LANE + 260;
let railY = rows.length ? rowTop[0] : 0;
railNodes.map(n => {
const l = links.find(k => k.b.node === n.id && plan[k.id]);
const f = l ? trayTop(plan[l.id].r) + TRAY_PAD + (plan[l.id].run.lane || 0) * LANE : Infinity;
return { n, f };
}).sort((a, b) => a.f - b.f || a.n.name.localeCompare(b.n.name)).forEach(({ n, f }) => {
const want = Number.isFinite(f) ? f - drawings[n.id].h / 2 : railY;
const top = Math.max(want, railY);
lay.pos[n.id] = { x: railX, y: top, w: sizes[n.id].w, dx: 0, rail: true };
railY = top + sizes[n.id].h + 120;
});
// ── Routes
links.forEach(l => {
const p = plan[l.id];
if (!p) return;
if (p.across) {
// Side by side in the top row: straight across from one card to the next
const [pa, pb] = [lay.pos[l.a.node], lay.pos[l.b.node]];
const left = pa.x < pb.x;
const A = { x: left ? pa.x + pa.w : pa.x, y: pa.y + CARD_H / 2, port: null };
const B = { x: left ? pb.x : pb.x + pb.w, y: pb.y + CARD_H / 2, port: null };
lay.routes[l.id] = { d: roundedPath([A, B]), mid: { x: (A.x + B.x) / 2, y: A.y - 40 }, A, B };
return;
}
const A = anchorOf(lay, l.a, true);
const B = anchorOf(lay, l.b, false);
const laneY = trayTop(p.r) + TRAY_PAD + (p.run.lane || 0) * LANE;
let pts;
if (p.toRail) pts = [A, { x: A.x, y: laneY }, { x: p.xb, y: laneY }, { x: p.xb, y: B.y }, B];
else if (p.run.straight) pts = [A, B];
else pts = [A, { x: A.x, y: laneY }, { x: B.x, y: laneY }, B];
// Traffic and "last seen" tags sit on the longest run
let mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
let best = -1;
for (let i = 1; i < pts.length; i++) {
const len = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
if (len > best) {
best = len;
mid = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 };
}
}
lay.routes[l.id] = { d: roundedPath(pts), mid, A, B };
});
const all = Object.entries(lay.pos);
lay.bounds = {
x0: Math.min(...all.map(([, q]) => q.x)) - 80,
y0: Math.min(...all.map(([, q]) => q.y)) - 80,
x1: Math.max(...all.map(([, q]) => q.x + q.w)) + 80,
y1: Math.max(...all.map(([id, q]) => q.y + sizes[id].h)) + 80,
};
return lay;
}
// ── Rendering ───────────────────────────────────────────────────────────
function linkTitle(link, byId) {
const end = e => {
const n = byId[e.node];
const name = n ? n.name : e.node;
if (e.port_idx != null) return `${name} port ${e.port_idx}${e.port_name && e.port_name !== `Port ${e.port_idx}` ? ` "${e.port_name}"` : ''}`;
return e.iface ? `${name} ${e.iface}` : name;
};
const bits = [`${end(link.a)} to ${end(link.b)}`, speedText(link.speed_mbps), MEDIUM_TEXT[link.medium] || ''];
if (link.ghost) bits.push(`last seen ${link.seen || ''}`);
else if (!link.up) bits.push('down');
return bits.filter(Boolean).join(' · ');
}
function renderLink(link, lay) {
const route = lay.routes[link.id];
if (!route) return { under: '', over: '' };
const { d, mid, A, B } = route;
const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)];
if (link.bundle) cls.push('tp-bundle');
if (link.ghost) cls.push('tp-ghost');
else if (!link.up) cls.push('tp-down');
if (link.wan) cls.push('tp-wan');
if (!link.wan && link.speed_mbps < 10000) cls.push('tp-slow');
const busy = (link.a_to_b_bps || 0) + (link.b_to_a_bps || 0);
const alerting = [link.a.node, link.b.node].some(id => (lay.byId[id] || {}).alert_count);
if (alerting) cls.push('tp-touches-alert');
const flowDur = Math.max(0.6, 6 - Math.log10(Math.max(busy, 1)) * 0.8).toFixed(2);
const flow = link.up && !link.ghost && busy > 1000
? `<path class="tp-flow" d="${d}" style="animation-duration:${flowDur}s"/>` : '';
const label = link.bundle
? `<g class="tp-tag tp-tag-bundle" transform="translate(${mid.x} ${mid.y})"><rect x="-100" y="-26" width="200" height="44" rx="22"/><text y="6" text-anchor="middle">${link.count} cables</text></g>`
: link.ghost
? `<g class="tp-tag tp-tag-ghost" transform="translate(${mid.x} ${mid.y})"><rect x="-110" y="-26" width="220" height="44" rx="22"/><text y="6" text-anchor="middle">last seen</text></g>`
: (link.up ? `<g class="tp-tag tp-rate" transform="translate(${mid.x} ${mid.y})"><rect x="-190" y="-26" width="380" height="44" rx="22"/>
<text y="6" text-anchor="middle">↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))}</text></g>` : '');
let warn = '';
if (link.name_mismatch) {
const at = link.name_mismatch.end === 'b' ? B : A;
const m = link.name_mismatch;
const text = `Port name says ${m.named}, but ${m.actual} is plugged in`;
warn = `<g class="tp-warn" transform="translate(${at.x + 34} ${at.y + (at.face === 'bottom' ? 40 : -40)})"><title>${esc(text)}</title>
<path d="M0 -26 L26 20 H-26 Z"/><text y="14" text-anchor="middle">!</text></g>`;
}
// A short tail from the card edge to the port on the picture, ending in the plug
const plug = P => (['dac', 'fibre', 'sfp'].includes(link.medium)
? `<circle class="tp-plug" cx="${P.x}" cy="${P.y}" r="7"/>`
: `<rect class="tp-boot" x="${P.x - 7}" y="${P.y - 7}" width="14" height="14" rx="3"/>`);
const tail = E => (E.port
? `<path class="tp-core tp-pigtail" d="M${E.x} ${E.y} L${E.port.x} ${E.port.y}"/>${plug(E.port)}` : '');
const plugs = `${tail(A)}${tail(B)}`;
const attrs = `class="${cls.join(' ')}" data-link="${esc(link.id)}"`;
return {
// The cable runs behind the devices; its plugs, traffic and warning sit on top
under: `<g ${attrs} tabindex="0" role="button" aria-label="${esc(linkTitle(link, lay.byId))}">
<title>${esc(linkTitle(link, lay.byId))}</title>
<path class="tp-casing" d="${d}"/><path class="tp-core" d="${d}"/>${flow}<path class="tp-hit" d="${d}"/>
</g>`,
over: `<g ${attrs} aria-hidden="true">${plugs}${label}${warn}</g>`,
};
}
function renderNode(node, lay) {
const p = lay.pos[node.id];
const d = lay.drawings[node.id];
const href = nodeHref(node);
const ring = node.severity
? `<rect class="tp-ring tp-ring-${esc(node.severity)}" x="-10" y="-10" width="${d.w + 20}" height="${d.h + 20}" rx="24"/>
<g class="tp-badge tp-badge-${esc(node.severity)}" transform="translate(${d.w - 6} 6)"><circle r="30"/><text y="11" text-anchor="middle">${node.alert_count}</text></g>`
: '';
const alertText = (node.alerts || []).map(a => `${a.severity}: ${a.type.replace(/_/g, ' ')}${a.detail ? ` ${a.detail}` : ''}`).join('; ');
const label = `${node.name}${node.kind === 'stub' ? ' (not identified)' : ''} · ${node.status}${alertText ? ` · ${alertText}` : ''}`;
const open = node.kind === 'group'
? `<g class="tp-node-link" tabindex="0" role="button" data-expand="${esc(node.switch)}" aria-label="${esc(`${node.name} on ${node.switch}: show them`)}">`
: (href ? `<a class="tp-node-link" href="${esc(href)}" aria-label="${esc(label)}">` : `<g class="tp-node-link" tabindex="0" aria-label="${esc(label)}">`);
const close = open.startsWith('<a') ? '</a>' : '</g>';
return `<g class="tp-node tp-kind-${esc(node.kind)} tp-st-${esc(node.status)}${node.alert_count ? ' tp-has-alert' : ''}" data-node="${esc(node.id)}" transform="translate(${p.x} ${p.y})">
${open}<title>${esc(label)}</title>
${d.svg}
${ring}
${close}
</g>`;
}
function applyView() {
const { k, tx, ty } = state.view;
document.getElementById('tp-world').setAttribute('transform', `translate(${tx} ${ty}) scale(${k})`);
const canvas = document.getElementById('tp-canvas');
canvas.classList.toggle('tp-far', k < 0.16);
canvas.classList.toggle('tp-near', k >= 0.6);
}
function applyFilterClasses() {
const canvas = document.getElementById('tp-canvas');
canvas.classList.toggle('tp-no-traffic', !state.filters.traffic);
canvas.classList.toggle('tp-only-10g', state.filters.tenG);
canvas.classList.toggle('tp-alerts-only', state.filters.alerts);
document.querySelectorAll('[data-filter]').forEach(el => {
if (el.tagName === 'SELECT') el.value = state.filters[el.dataset.filter];
else el.checked = !!state.filters[el.dataset.filter];
});
}
function render() {
const data = state.data;
if (!data) return;
state.layout = layout(data, state.filters, state.expanded);
const lay = state.layout;
const world = document.getElementById('tp-world');
const cables = lay.links.map(l => renderLink(l, lay));
world.innerHTML = `<g class="tp-links">${cables.map(c => c.under).join('')}</g>
<g class="tp-nodes">${lay.nodes.map(n => renderNode(n, lay)).join('')}</g>
<g class="tp-links-over">${cables.map(c => c.over).join('')}</g>`;
document.getElementById('tp-loading').hidden = true;
applyFilterClasses();
if (!state.fitted) {
sizeCanvas();
fit();
state.fitted = true;
} else {
applyView();
}
if (state.selected) {
const link = data.links.find(l => l.id === state.selected);
if (link) showPanel(link);
else hidePanel();
}
}
// ── Cable panel ─────────────────────────────────────────────────────────
function endHtml(end, byId) {
const n = byId[end.node] || { name: end.node, kind: '' };
const href = n.kind && n.kind !== 'internet' ? nodeHref(n) : '';
const name = href ? `<a class="device-link" href="${esc(href)}">${esc(n.name)}</a>` : esc(n.name);
let where = '';
if (end.port_idx != null) {
where = `<a class="device-link" href="${esc(deviceHref(n.kind === 'stub' ? n.switch : n.name))}#port-${esc(end.port_idx)}">port ${esc(end.port_idx)}</a>`;
if (end.port_name && end.port_name !== `Port ${end.port_idx}`) where += ` "${esc(end.port_name)}"`;
} else if (end.iface) {
where = esc(end.iface);
} else if (end.port_name) {
where = esc(end.port_name);
}
return `${name}${where ? ` · ${where}` : ''}`;
}
function showPanel(link) {
const byId = Object.fromEntries(state.data.nodes.map(n => [n.id, n]));
const panel = document.getElementById('tp-panel');
const rows = [];
const row = (k, v) => rows.push(`<tr><th scope="row">${k}</th><td>${v}</td></tr>`);
row('From', endHtml(link.a, byId));
row('To', endHtml(link.b, byId));
row('State', link.ghost ? `<span class="tp-bad">not seen now</span> · last seen ${esc(link.seen || 'unknown')}` : (link.up ? 'up' : '<span class="tp-bad">down</span>'));
if (link.speed_mbps) row('Speed', esc(speedText(link.speed_mbps)));
row('Cable', esc(MEDIUM_TEXT[link.medium] || link.medium));
if (link.sfp_part || link.sfp_vendor) {
row('Module', esc([link.sfp_vendor, link.sfp_part].filter(Boolean).join(' ')) + (link.sfp_serial ? `<br><span class="tp-dim-text">S/N ${esc(link.sfp_serial)}</span>` : ''));
}
if (!link.ghost && link.up) {
row('Down (from the internet side)', esc(rateText(link.a_to_b_bps)));
row('Up', esc(rateText(link.b_to_a_bps)));
if (link.utilisation != null) row('Busiest direction', `${(link.utilisation * 100).toFixed(link.utilisation < 0.01 ? 2 : 1)}% of ${esc(speedText(link.speed_mbps))}`);
row('Errors', link.errors ? `<span class="tp-bad">${esc(link.errors)}/s</span>` : 'none');
row('Drops', link.drops ? `<span class="tp-warn-text">${esc(link.drops)}/s</span>` : 'none');
}
if (link.cable) {
const label = link.cable.label || `cable #${link.cable.id}`;
row('Cutsheet', `<a class="device-link" href="/cutsheet#cable-${esc(link.cable.id)}">${esc(label)}</a>${link.cable.type ? ` · ${esc(link.cable.type)}` : ''}${link.cable.color ? ` · ${esc(link.cable.color)}` : ''}`);
} else if (!link.wan) {
row('Cutsheet', '<span class="tp-dim-text">not documented</span>');
}
const warn = link.name_mismatch
? `<p class="tp-panel-warn">⚠ The port is named after <strong>${esc(link.name_mismatch.named)}</strong>, but <strong>${esc(link.name_mismatch.actual)}</strong> is plugged into it. Rename the port in UniFi, or check the cable.</p>`
: '';
panel.innerHTML = `<div class="tp-panel-head">
<h2 class="tp-panel-title">Cable</h2>
<button type="button" class="lt-modal-close" data-action="close-panel" aria-label="Close cable details">&#x2715;</button>
</div>${warn}<table class="tp-panel-table"><tbody>${rows.join('')}</tbody></table>`;
panel.hidden = false;
state.selected = link.id;
document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected'));
document.querySelectorAll(`.tp-link[data-link="${CSS.escape(link.id)}"]`).forEach(el => el.classList.add('tp-selected'));
}
function hidePanel() {
document.getElementById('tp-panel').hidden = true;
state.selected = null;
document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected'));
}
// ── Path to the internet ────────────────────────────────────────────────
function upstream(id) {
const links = state.layout.links;
const nodes = new Set([id]);
const hot = new Set();
const queue = [id];
while (queue.length) {
const cur = queue.shift();
links.forEach(l => {
if (l.b.node === cur && !l.ghost) {
hot.add(l.id);
if (!nodes.has(l.a.node)) {
nodes.add(l.a.node);
queue.push(l.a.node);
}
}
});
}
return { nodes, hot };
}
function highlight(id) {
const canvas = document.getElementById('tp-canvas');
document.querySelectorAll('.tp-hot').forEach(el => el.classList.remove('tp-hot'));
if (!id || !state.layout) {
canvas.classList.remove('tp-tracing');
return;
}
const { nodes, hot } = upstream(id);
nodes.forEach(n => document.querySelector(`.tp-node[data-node="${CSS.escape(n)}"]`)?.classList.add('tp-hot'));
hot.forEach(l => document.querySelectorAll(`.tp-link[data-link="${CSS.escape(l)}"]`).forEach(el => el.classList.add('tp-hot')));
canvas.classList.add('tp-tracing');
}
// ── Pan and zoom ────────────────────────────────────────────────────────
const K_MIN = 0.03;
const K_MAX = 3;
// On a narrow screen the diagram is much wider than tall: size the canvas to it instead of
// leaving most of a tall canvas empty
function sizeCanvas() {
const canvas = document.getElementById('tp-canvas');
if (!state.layout || canvas.clientWidth > 700) {
canvas.style.height = '';
return;
}
const { x0, y0, x1, y1 } = state.layout.bounds;
const want = canvas.clientWidth * (y1 - y0) / (x1 - x0);
canvas.style.height = `${Math.round(Math.max(440, Math.min(want + 24, window.innerHeight * 0.75)))}px`;
}
function fit() {
if (!state.layout) return;
const canvas = document.getElementById('tp-canvas');
const { x0, y0, x1, y1 } = state.layout.bounds;
const cw = canvas.clientWidth, ch = canvas.clientHeight;
const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(cw / (x1 - x0), ch / (y1 - y0))));
state.view = { k, tx: (cw - (x1 - x0) * k) / 2 - x0 * k, ty: (ch - (y1 - y0) * k) / 2 - y0 * k };
applyView();
}
function zoomAt(factor, px, py) {
const v = state.view;
const k = Math.max(K_MIN, Math.min(K_MAX, v.k * factor));
const wx = (px - v.tx) / v.k, wy = (py - v.ty) / v.k;
state.view = { k, tx: px - wx * k, ty: py - wy * k };
applyView();
}
function zoomCentre(factor) {
const canvas = document.getElementById('tp-canvas');
zoomAt(factor, canvas.clientWidth / 2, canvas.clientHeight / 2);
}
function initPanZoom() {
const canvas = document.getElementById('tp-canvas');
const svg = document.getElementById('tp-svg');
const pointers = new Map();
let drag = null;
let pinch = null;
let suppressClick = false;
const local = e => {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
svg.addEventListener('pointerdown', e => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
pointers.set(e.pointerId, local(e));
if (pointers.size === 1) {
drag = { start: local(e), view: Object.assign({}, state.view), id: e.pointerId, moved: false };
} else if (pointers.size === 2) {
const [p, q] = [...pointers.values()];
pinch = { dist: Math.hypot(p.x - q.x, p.y - q.y), k: state.view.k };
drag = null;
}
});
svg.addEventListener('pointermove', e => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, local(e));
if (pinch && pointers.size === 2) {
const [p, q] = [...pointers.values()];
const dist = Math.hypot(p.x - q.x, p.y - q.y);
zoomAt((pinch.k * dist / pinch.dist) / state.view.k, (p.x + q.x) / 2, (p.y + q.y) / 2);
suppressClick = true;
return;
}
if (!drag || drag.id !== e.pointerId) return;
const pt = local(e);
const dx = pt.x - drag.start.x, dy = pt.y - drag.start.y;
if (!drag.moved && Math.hypot(dx, dy) < 5) return;
if (!drag.moved) {
drag.moved = true;
svg.setPointerCapture(e.pointerId);
canvas.classList.add('tp-dragging');
}
state.view = { k: drag.view.k, tx: drag.view.tx + dx, ty: drag.view.ty + dy };
applyView();
});
const end = e => {
pointers.delete(e.pointerId);
if (drag && drag.id === e.pointerId && drag.moved) suppressClick = true;
if (pointers.size < 2) pinch = null;
if (!pointers.size) drag = null;
canvas.classList.remove('tp-dragging');
};
svg.addEventListener('pointerup', end);
svg.addEventListener('pointercancel', end);
// A drag that ends over a device is not a click on it
svg.addEventListener('click', e => {
if (suppressClick) {
e.preventDefault();
e.stopPropagation();
suppressClick = false;
}
}, true);
svg.addEventListener('wheel', e => {
e.preventDefault();
const pt = local(e);
zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), pt.x, pt.y);
}, { passive: false });
canvas.addEventListener('keydown', e => {
if (e.target.closest('input, select, textarea, .tp-panel')) return;
if (e.key === '+' || e.key === '=') { zoomCentre(1.25); e.preventDefault(); }
else if (e.key === '-' || e.key === '_') { zoomCentre(0.8); e.preventDefault(); }
else if (e.key === '0') { fit(); e.preventDefault(); }
else if (e.key === 'Escape') hidePanel();
});
}
// ── Events ──────────────────────────────────────────────────────────────
function initEvents() {
const svg = document.getElementById('tp-svg');
svg.addEventListener('click', e => {
const port = e.target.closest('[data-action="select-port"]');
if (port) {
e.preventDefault();
window.location.href = `${deviceHref(port.dataset.switch)}#port-${encodeURIComponent(port.dataset.portIdx)}`;
return;
}
const group = e.target.closest('[data-expand]');
const link = e.target.closest('.tp-link');
if (group || (link && link.dataset.link.startsWith('bundle:'))) {
state.expanded.add(group ? group.dataset.expand : link.dataset.link.slice('bundle:'.length));
render();
return;
}
if (link) {
const found = state.data.links.find(l => l.id === link.dataset.link);
if (found) showPanel(found);
return;
}
if (!e.target.closest('.tp-node')) hidePanel();
});
svg.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const group = e.target.closest('[data-expand]');
if (group) {
e.preventDefault();
state.expanded.add(group.dataset.expand);
render();
return;
}
const link = e.target.closest('.tp-link');
if (link) {
e.preventDefault();
const found = state.data.links.find(l => l.id === link.dataset.link);
if (found) showPanel(found);
}
});
const trace = e => {
const node = e.target.closest('.tp-node');
highlight(node ? node.dataset.node : null);
};
svg.addEventListener('mouseover', trace);
svg.addEventListener('focusin', trace);
svg.addEventListener('mouseleave', () => highlight(null));
svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); });
document.getElementById('tp-panel').addEventListener('click', e => {
if (e.target.closest('[data-action="close-panel"]')) hidePanel();
});
document.querySelectorAll('[data-filter]').forEach(el => {
el.addEventListener('change', () => {
state.filters[el.dataset.filter] = el.tagName === 'SELECT' ? el.value : el.checked;
saveFilters();
if (el.dataset.filter === 'stubs') state.expanded.clear();
if (el.dataset.filter === 'stubs' || el.dataset.filter === 'power') render();
else applyFilterClasses();
});
});
document.querySelectorAll('[data-zoom]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.zoom === 'fit') fit();
else zoomCentre(btn.dataset.zoom === 'in' ? 1.25 : 0.8);
});
});
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyView, 150);
});
}
// ── Data ────────────────────────────────────────────────────────────────
async function refresh() {
try {
const data = await lt.api.get('/api/topology');
state.data = data;
// The layout follows from the devices and cables alone, so a redraw with the same ones keeps
// everything where it was; the view (pan and zoom) is kept either way
render();
const warn = document.getElementById('tp-warnings');
warn.hidden = !(data.warnings && data.warnings.length);
warn.innerHTML = (data.warnings || []).map(w => `<div>${esc(w)}</div>`).join('');
document.getElementById('tp-updated').textContent = data.updated ? `Port data from ${data.updated}` : '';
} catch (e) {
document.getElementById('tp-loading').textContent = `Topology unavailable: ${e.message || e}`;
document.getElementById('tp-loading').hidden = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
initPanZoom();
initEvents();
applyFilterClasses();
refresh();
setInterval(() => { if (!document.hidden) refresh(); }, REFRESH_MS);
});