Files
gandalf/static/topology.js
jaredandClaude Opus 5.5 415d2222e7
Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Successful in 2m36s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Test / Python Tests (pytest) (push) Successful in 2m46s
Lint / JS (eslint) (pull_request) Successful in 15s
Lint / Python (flake8) (pull_request) Successful in 2m14s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (push) Successful in 1m33s
Security / Python Security (bandit) (pull_request) Successful in 54s
Test / Python Tests (pytest) (pull_request) Successful in 1m3s
Topology: power map, what each outlet feeds
- Each PDU, UPS and power strip card lists its outlets that feed
  something, draw power or are off, with live watts. Each device fed
  gets a power badge with what it draws. Hovering a power card or a
  device draws its feeds as dotted lines and keeps both ends lit.
- What an outlet feeds: a saved assignment, else the outlet's UniFi name
  when it names a device (or the modem), else a MAC in its name that
  belongs to a host NIC (the monitor's host_macs) or UniFi device.
- Admins click an outlet to set what it feeds, nothing, or automatic.
  Saved in a new power_feeds table (created with the cutsheet tables);
  PUT /api/topology/power/<device>/<outlet>, admin and JSON only, with
  the device, outlet and target checked. UniFi is never changed.
- An outlet that is off but feeds something is listed under "to look at".
- Power gear is shown by default again, now that it has something to say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:28:13 -05:00

1391 lines
63 KiB
JavaScript

'use strict';
/* global TOPO_CAN_EDIT, 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, mgmt: 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';
delete saved.tenG;
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);
}
// ── Problems ────────────────────────────────────────────────────────────
// Anything that needs a look: drawn in full colour, always shown whatever the filters, and listed
// above the diagram. Healthy cables are drawn quietly.
function linkProblem(link) {
if (link.bundle) return '';
if (link.ghost) return 'ghost';
if (!link.up) return 'down';
if (link.name_mismatch) return 'mismatch';
if ((link.errors || 0) > 0) return 'errors';
return '';
}
function nodeProblem(node) {
if (['stub', 'group', 'internet'].includes(node.kind) && !node.alert_count) return node.kind === 'internet' && node.status === 'down' ? 'down' : '';
if (node.status === 'down' || node.status === 'degraded') return node.status;
return node.alert_count ? 'alert' : '';
}
function endText(e, byId) {
const n = byId[e.node];
const name = n ? n.name : e.node;
if (e.port_idx != null) return `${name} port ${e.port_idx}`;
return e.iface ? `${name} ${e.iface}` : name;
}
function problemsOf(data) {
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
const out = [];
data.nodes.forEach(n => {
const p = nodeProblem(n);
if (!p) return;
const bits = [];
if (n.status === 'down' || n.status === 'degraded') bits.push(n.status);
if (n.alert_count) bits.push(`${n.alert_count} alert${n.alert_count > 1 ? 's' : ''}`);
out.push({ kind: 'node', id: n.id, severity: n.status === 'down' || n.severity === 'critical' ? 'bad' : 'warn',
text: `${n.name}: ${bits.join(', ')}` });
});
(data.power || []).forEach(src => src.outlets.forEach(o => {
if (o.on || !o.target) return;
const down = (byId[o.target] || {}).status === 'down';
out.push({ kind: 'node', id: o.target, severity: down ? 'bad' : 'warn',
text: `${src.node} outlet ${o.index} is off, and it feeds ${o.target}` });
}));
data.links.forEach(l => {
const p = linkProblem(l);
if (!p) return;
// A missing device is one problem, not two: say where it was last seen on its own entry
const listed = out.find(o => o.kind === 'node' && o.id === l.b.node);
if (p === 'ghost' && listed) {
listed.text += `, last seen on ${endText(l.a, byId)}`;
return;
}
const host = byId[l.b.node] && byId[l.b.node].kind === 'stub' ? `${byId[l.b.node].name} on ${endText(l.a, byId)}` : null;
let text;
if (p === 'ghost') text = `${byId[l.b.node] ? byId[l.b.node].name : l.b.node} not seen, last on ${endText(l.a, byId)}`;
else if (p === 'down') text = `Cable down: ${host || `${endText(l.a, byId)} to ${endText(l.b, byId)}`}`;
else if (p === 'mismatch') {
const m = l.name_mismatch;
const end = m.end === 'b' ? l.b : l.a;
text = `${endText(end, byId)} is named after ${m.named}, but ${m.actual} is plugged in`;
} else text = `Errors on ${endText(l.a, byId)} to ${endText(l.b, byId)} (${l.errors}/s)`;
out.push({ kind: 'link', id: l.id, severity: p === 'mismatch' || p === 'errors' ? 'warn' : 'bad', text });
});
return out;
}
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);
}
}
// ── Power ───────────────────────────────────────────────────────────────
function wattText(w) {
return w == null ? '' : `${w >= 100 ? Math.round(w) : (+w).toFixed(1)} W`;
}
function powerOf(name) {
return ((state.data && state.data.power) || []).find(p => p.node === name);
}
// The outlets worth listing on a power card: those feeding something, drawing power, or off
function listedOutlets(src) {
return src.outlets.filter(o => o.target || (o.power_w || 0) >= 0.5 || !o.on);
}
const ROW_H = 46;
// ── 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;
};
}
// Power gear lists its outlets under the picture: what each feeds and what it draws
let h = CARD_H;
let rowsSvg = '';
const rows = {};
const src = node.power ? powerOf(node.name) : null;
if (src) {
const list = listedOutlets(src);
rowsSvg = list.map((o, i) => {
const y = CARD_H + i * ROW_H;
rows[o.index] = y + ROW_H / 2;
const what = o.target || (o.match === 'cleared' ? 'nothing' : 'unassigned');
const cls = `tp-outlet-row${o.on ? '' : ' tp-outlet-off'}${o.target ? '' : ' tp-outlet-free'}`;
const tip = `${node.name} outlet ${o.index}${o.name ? ` "${o.name}"` : ''} · ${o.on ? 'on' : 'off'}${o.power_w != null ? ` · ${wattText(o.power_w)}` : ''} · feeds ${what}${o.match === 'saved' ? ' (set here)' : o.match === 'name' ? ' (from its name)' : o.match === 'mac' ? ' (from the MAC in its name)' : ''}${TOPO_CAN_EDIT ? ' · click to change' : ''}`;
return `<g class="${cls}" data-outlet-source="${esc(node.name)}" data-outlet="${o.index}"${TOPO_CAN_EDIT ? ' role="button" tabindex="0"' : ''}>
<title>${esc(tip)}</title>
<rect class="tp-outlet-hit" x="16" y="${y + 4}" width="${w - 32}" height="${ROW_H - 8}" rx="8"/>
<text class="tp-outlet-idx" x="34" y="${y + 31}">${o.index}</text>
<text class="tp-outlet-target" x="96" y="${y + 31}">${esc(what.length > 22 ? `${what.slice(0, 21)}…` : what)}</text>
<text class="tp-outlet-watts" x="${w - 34}" y="${y + 31}" text-anchor="end">${o.on ? esc(wattText(o.power_w)) : 'off'}</text>
</g>`;
}).join('');
h = CARD_H + list.length * ROW_H + (list.length ? 16 : 0);
}
// What feeds this device, as a badge by its name
let badge = '';
if ((node.power_in || []).length) {
const off = node.power_in.some(f => !f.on);
const watts = node.power_in.reduce((t, f) => t + (f.power_w || 0), 0);
const tip = node.power_in.map(f => `${f.source} outlet ${f.outlet}${f.on ? '' : ' (off)'}${f.power_w != null ? ` · ${wattText(f.power_w)}` : ''}`).join('; ');
badge = `<text class="tp-power-badge${off ? ' tp-power-off' : ''}" x="${w - 34}" y="108" text-anchor="end"><title>${esc(`Fed by ${tip}`)}</title>⚡ ${off ? 'off' : esc(wattText(watts) || 'fed')}</text>`;
}
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, src && src.power_w != null ? `${wattText(src.power_w)} total` : '',
node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
// The second line shares its space with the power badge
const subMax = Math.floor((w - (badge ? 250 : 70)) / 16.8);
if (sub.length > subMax) sub = `${sub.slice(0, subMax - 1)}…`;
const svg = `<rect class="tp-card" width="${w}" height="${h}" rx="18"/>
<rect class="tp-card-stripe tp-stripe-${esc(node.status)}" x="0" y="18" width="8" height="${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"/>${badge}
<rect class="tp-band" x="20" y="${BAND_Y - 8}" width="${w - 40}" height="${BAND_H + 16}" rx="10"/>
${band}${rowsSvg}`;
return { w, h, svg, anchorAt, rows };
}
// ── 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;
const all = Object.fromEntries(data.nodes.map(n => [n.id, n]));
let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power));
let links = data.links.slice();
if (!filters.mgmt) {
// A server's slower second link (its 1G management cable) is hidden while it is healthy and
// the server has a faster one; a problem cable is always drawn
links = links.filter(l => {
const host = [l.a, l.b].find(e => (all[e.node] || {}).kind === 'host');
if (!host || linkProblem(l)) return true;
const faster = data.links.some(o => o !== l && [o.a.node, o.b.node].includes(host.node) && (o.speed_mbps || 0) > (l.speed_mbps || 0));
return !faster;
});
}
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 = {};
data.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');
const problem = linkProblem(link);
if (problem) cls.push('tp-problem', `tp-problem-${problem}`);
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>
${problem ? `<path class="tp-halo" d="${d}"/>` : ''}<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' : ''}${nodeProblem(node) ? ' tp-node-problem' : ''}" 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-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>
<g class="tp-feeds" id="tp-feeds"></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 };
}
// Power feeds, drawn while a power unit or a device it feeds is under the pointer: dotted lines
// from the outlet's row on the power card to the device
function drawFeeds(id) {
const lay = state.layout;
const g = document.getElementById('tp-feeds');
if (!g || !lay) return [];
const pairs = [];
((state.data && state.data.power) || []).forEach(src => {
const sp = lay.pos[src.node];
if (!sp) return;
src.outlets.forEach(o => {
if (!o.target || !lay.pos[o.target] || (id !== src.node && id !== o.target)) return;
const rowY = lay.drawings[src.node].rows[o.index];
if (rowY == null) return;
pairs.push({ o, from: { x: sp.x, y: sp.y + rowY }, to: lay.pos[o.target], toH: lay.drawings[o.target].h, target: o.target });
});
});
g.innerHTML = pairs.map(({ o, from, to, toH, target }) => {
const B = { x: to.x + to.w, y: to.y + toH / 2 };
if (to.rail) { B.x = to.x; }
const c = Math.max(160, Math.abs(from.x - B.x) * 0.4);
const d = `M${from.x} ${from.y} C${from.x - c} ${from.y} ${B.x + c} ${B.y} ${B.x} ${B.y}`;
const mx = (from.x + B.x) / 2, my = (from.y + B.y) / 2;
return `<g class="tp-feed${o.on ? '' : ' tp-feed-off'}"><path d="${d}"/>
<circle cx="${B.x}" cy="${B.y}" r="10"/>
<g class="tp-tag tp-feed-tag" transform="translate(${mx} ${my})"><rect x="-150" y="-26" width="300" height="44" rx="22"/>
<text y="6" text-anchor="middle">⚡ ${esc(target)} ${o.on ? esc(wattText(o.power_w)) : 'off'}</text></g></g>`;
}).join('');
// Everything at either end of a feed stays lit while the path is traced
return new Set(pairs.flatMap(p => [p.target, ...((state.data.power || []).filter(src => src.outlets.includes(p.o)).map(src => src.node))]));
}
function highlight(id) {
const canvas = document.getElementById('tp-canvas');
document.querySelectorAll('.tp-hot').forEach(el => el.classList.remove('tp-hot'));
const feeds = document.getElementById('tp-feeds');
if (feeds) feeds.innerHTML = '';
if (!id || !state.layout) {
canvas.classList.remove('tp-tracing');
return;
}
const fed = drawFeeds(id);
const { nodes, hot } = upstream(id);
fed.forEach(n => nodes.add(n));
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 outletRow = e.target.closest('[data-outlet]');
if (outletRow && TOPO_CAN_EDIT) {
e.preventDefault();
openOutletEditor(outletRow, e);
return;
}
if (!e.target.closest('#tp-pop')) closeOutletEditor();
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 outletRow = e.target.closest('[data-outlet]');
if (outletRow && TOPO_CAN_EDIT) {
e.preventDefault();
openOutletEditor(outletRow, null);
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-pop').addEventListener('click', e => {
const b = e.target.closest('[data-pop]');
if (!b) return;
if (b.dataset.pop === 'save') saveOutlet();
else closeOutletEditor();
});
document.getElementById('tp-pop').addEventListener('keydown', e => {
if (e.key === 'Escape') closeOutletEditor();
});
document.getElementById('tp-summary').addEventListener('click', e => {
const chip = e.target.closest('.tp-problem-chip');
if (!chip) return;
if (chip.dataset.focusNode) focusOn('node', chip.dataset.focusNode);
else focusOn('link', chip.dataset.focusLink);
});
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 (['stubs', 'power', 'mgmt'].includes(el.dataset.filter)) 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);
});
}
// ── Outlet editor (admins) ──────────────────────────────────────────────
function closeOutletEditor() {
const pop = document.getElementById('tp-pop');
if (pop) pop.hidden = true;
}
function openOutletEditor(row, evt) {
const pop = document.getElementById('tp-pop');
const source = row.dataset.outletSource;
const index = Number(row.dataset.outlet);
const src = powerOf(source);
const o = src && src.outlets.find(x => x.index === index);
if (!o) return;
const names = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind) && n.name !== source)
.map(n => n.name).sort((a, b) => a.localeCompare(b));
const current = o.match === 'saved' ? o.target : (o.match === 'cleared' ? '__none' : '__auto');
const opt = (v, t) => `<option value="${esc(v)}"${v === current ? ' selected' : ''}>${esc(t)}</option>`;
pop.innerHTML = `<div class="tp-pop-title">${esc(source)} outlet ${index}</div>
<div class="tp-pop-sub">${esc(o.name || 'no name in UniFi')} · ${o.on ? esc(wattText(o.power_w) || 'on') : 'off'}</div>
<label class="tp-pop-label" for="tp-pop-select">Feeds</label>
<select class="lt-select" id="tp-pop-select">
${opt('__auto', `Automatic (${o.match === 'name' || o.match === 'mac' ? o.target : 'from its name: nothing'})`)}
${opt('__none', 'Nothing')}
${names.map(n => opt(n, n)).join('')}
</select>
<p class="tp-pop-hint">Saved in Gandalf only; the outlet keeps its name in UniFi.</p>
<div class="tp-pop-actions">
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-pop="cancel">Cancel</button>
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-pop="save">Save</button>
</div>`;
const canvas = document.getElementById('tp-canvas').getBoundingClientRect();
const r = evt ? { x: evt.clientX, y: evt.clientY } : (() => { const b = row.getBoundingClientRect(); return { x: b.left, y: b.bottom }; })();
pop.hidden = false;
const left = Math.min(Math.max(8, r.x - canvas.left - pop.offsetWidth - 12), canvas.width - pop.offsetWidth - 8);
const top = Math.min(Math.max(8, r.y - canvas.top - 20), canvas.height - pop.offsetHeight - 8);
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
pop.dataset.source = source;
pop.dataset.outlet = String(index);
document.getElementById('tp-pop-select').focus();
}
async function saveOutlet() {
const pop = document.getElementById('tp-pop');
const v = document.getElementById('tp-pop-select').value;
const target = v === '__auto' ? null : (v === '__none' ? '' : v);
try {
await lt.api.put(`/api/topology/power/${encodeURIComponent(pop.dataset.source)}/${encodeURIComponent(pop.dataset.outlet)}`, { target });
closeOutletEditor();
lt.toast.success('Outlet saved');
refresh();
} catch (e) {
lt.toast.error(`Could not save: ${e.message || e}`);
}
}
// ── Problem summary ─────────────────────────────────────────────────────
function renderSummary() {
const box = document.getElementById('tp-summary');
const problems = problemsOf(state.data);
const devices = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind)).length;
const cables = state.data.links.length;
if (!problems.length) {
box.className = 'tp-summary tp-summary-ok';
box.innerHTML = `<span class="tp-summary-head">✓ All clear</span><span class="tp-summary-text">${devices} devices and ${cables} cables, nothing down or mislabelled</span>`;
return;
}
box.className = `tp-summary ${problems.some(p => p.severity === 'bad') ? 'tp-summary-bad' : 'tp-summary-warn'}`;
box.innerHTML = `<span class="tp-summary-head">⚠ ${problems.length} to look at</span>
${problems.map(p => `<button type="button" class="tp-problem-chip tp-chip-${p.severity}" data-focus-${p.kind}="${esc(p.id)}">${esc(p.text)}</button>`).join('')}`;
}
// Centre the view on a device or cable and show what is wrong with it
function focusOn(kind, id) {
const lay = state.layout;
const canvas = document.getElementById('tp-canvas');
let x, y;
if (kind === 'node') {
const p = lay.pos[id];
if (!p) return;
x = p.x + p.w / 2;
y = p.y + lay.drawings[id].h / 2;
} else {
const r = lay.routes[id];
if (!r) return;
x = r.mid.x;
y = r.mid.y;
}
const k = Math.max(state.view.k, 0.55);
state.view = { k, tx: canvas.clientWidth / 2 - x * k, ty: canvas.clientHeight / 2 - y * k };
applyView();
if (kind === 'link') {
const link = state.data.links.find(l => l.id === id);
if (link) showPanel(link);
} else {
hidePanel();
highlight(id);
}
canvas.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── 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();
renderSummary();
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);
});