Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Successful in 1m20s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 1m44s
Lint / JS (eslint) (pull_request) Successful in 30s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m42s
Test / Python Tests (pytest) (pull_request) Successful in 1m16s
The dashboard's hand-drawn diagram was out of date. /topology replaces it with the network as the switches report it, every device drawn to scale from its faceplate or cutsheet front, and one cable per physical link leaving the port it is plugged into. - topology.py builds the graph: switch-to-switch cables seen from both ends are merged, servers get one cable per NIC, the gateway's internet port goes to the modem and on to the internet, unknown neighbours are stubs, and gear with no live cable is drawn where it was last seen. - Ports named after a different device than the one plugged in are flagged. - Cables show medium, speed, state and traffic; click one for its details, hover a device to trace its path to the internet; pan, zoom and filters. - The monitor now keeps host_macs (MAC to host NIC) in link_stats. - The old dashboard diagram and its JS and CSS are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
843 lines
37 KiB
JavaScript
843 lines
37 KiB
JavaScript
'use strict';
|
|
/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */
|
|
// Topology page: every device drawn as its real faceplate or chassis, with each cable leaving
|
|
// the exact port it is plugged into. Data comes from /api/topology (topology.build_graph).
|
|
//
|
|
// World units: a 19" panel is 1100 wide (as on the rack page), so everything is to scale.
|
|
|
|
const esc = s => lt.escHtml(s == null ? '' : String(s));
|
|
const PER_MM = 1100 / 482.6;
|
|
const ROW_GAP = 330;
|
|
const NODE_GAP = 120;
|
|
const LABEL_H = 104;
|
|
const RAIL_GAP = 520;
|
|
const STRIP_H = 104;
|
|
const SOCKET_W = 118;
|
|
const REFRESH_MS = 30000;
|
|
const FILTER_KEY = 'gandalf.topology.filters';
|
|
const DEFAULT_FILTERS = { stubs: true, power: true, traffic: true, tenG: false, alerts: false };
|
|
|
|
const state = {
|
|
data: null,
|
|
layout: null,
|
|
view: { k: 1, tx: 0, ty: 0 },
|
|
fitted: false,
|
|
filters: loadFilters(),
|
|
selected: null,
|
|
};
|
|
|
|
function loadFilters() {
|
|
try {
|
|
return Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'));
|
|
} 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 === '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];
|
|
const box = (opts.rack && spec.rackView) || [0, 0, spec.width, 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);
|
|
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 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: its drawn front (from the cutsheet's Front drawing) under a strip of its NIC sockets,
|
|
// one per physical interface, so each cable lands on the NIC it is plugged into.
|
|
function hostDrawing(node, linksByIface) {
|
|
let front;
|
|
const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] };
|
|
if (RACK_FRONTS[node.chassis]) {
|
|
const spec = RACK_FRONTS[node.chassis];
|
|
front = {
|
|
w: spec.width, h: spec.height,
|
|
svg: nested(rackFrontSvg(node.chassis, ctx)).replace('<svg ', `<svg width="${spec.width}" height="${spec.height}" `),
|
|
};
|
|
} else if (SHELF_GEAR[node.chassis]) {
|
|
const gear = SHELF_GEAR[node.chassis];
|
|
const w = gear.mm * PER_MM;
|
|
const h = gear.height * (w / gear.width);
|
|
front = {
|
|
w, h,
|
|
svg: `<svg width="${w}" height="${h}" viewBox="0 0 ${gear.width} ${gear.height}"><title>${esc(`${node.name} · ${gear.label}`)}</title>${gear.draw(ctx)}</svg>`,
|
|
};
|
|
} else if (CHASSIS_FACEPLATES[node.chassis]) {
|
|
front = faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100);
|
|
} else {
|
|
front = {
|
|
w: 560, h: 150,
|
|
svg: `<rect class="tp-host-box" width="560" height="150" rx="8"/>
|
|
<circle class="tp-host-led tp-led-${node.status === 'up' ? 'on' : (node.status === 'down' ? 'bad' : 'off')}" cx="520" cy="75" r="12"/>
|
|
<text class="tp-box-text" x="250" y="88" text-anchor="middle">server</text>
|
|
<title>${esc(node.name)}: no front drawing on the cutsheet</title>`,
|
|
};
|
|
}
|
|
|
|
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 stripW = Math.max(nics.length * SOCKET_W + 24, 200);
|
|
const w = Math.max(front.w, stripW);
|
|
const sx = (w - stripW) / 2;
|
|
const fx = (w - front.w) / 2;
|
|
const pos = {};
|
|
const sockets = nics.map((nic, i) => {
|
|
const link = linksByIface[nic.name];
|
|
const cx = sx + 12 + i * SOCKET_W + SOCKET_W / 2;
|
|
pos[nic.name] = cx;
|
|
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="14" width="60" height="34" rx="3"/><rect class="tp-nic-sfp-in" x="${cx - 22}" y="20" width="44" height="22" rx="2"/>`
|
|
: `<path class="tp-nic-rj45" d="M${cx - 28} 20 h16 v-8 h24 v8 h16 v32 h-56 Z"/>`;
|
|
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="60" r="5"/>
|
|
<text class="tp-nic-name" x="${cx}" y="88" text-anchor="middle">${esc(nic.name.length > 11 ? `${nic.name.slice(0, 10)}…` : nic.name)}</text></g>`;
|
|
}).join('');
|
|
return {
|
|
w, h: STRIP_H + 14 + front.h,
|
|
svg: `<rect class="tp-strip" x="${sx}" y="0" width="${stripW}" height="${STRIP_H}" rx="10"/>${sockets}<g transform="translate(${fx} ${STRIP_H + 14})">${front.svg}</g>`,
|
|
anchor: end => (end.iface && pos[end.iface] != null ? { x: pos[end.iface], y: 14, face: 'top' } : null),
|
|
};
|
|
}
|
|
|
|
function drawingFor(node, linksByIface) {
|
|
const spec = FACEPLATES[node.model];
|
|
switch (node.kind) {
|
|
case 'internet': return internetDrawing();
|
|
case 'modem': return modemDrawing(node);
|
|
case 'host': return hostDrawing(node, linksByIface);
|
|
case 'stub': return stubDrawing();
|
|
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' });
|
|
if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node);
|
|
return boxDrawing(node, node.model || node.kind);
|
|
}
|
|
}
|
|
|
|
// ── Layout ──────────────────────────────────────────────────────────────
|
|
function visibleGraph(data, filters) {
|
|
const nodes = data.nodes.filter(n => (filters.stubs || n.kind !== 'stub') && (filters.power || !n.power));
|
|
const ids = new Set(nodes.map(n => n.id));
|
|
const links = data.links.filter(l => ids.has(l.a.node) && ids.has(l.b.node));
|
|
return { nodes, links };
|
|
}
|
|
|
|
function rowKey(node) {
|
|
if (node.kind === 'host') return 1e6;
|
|
if (node.depth == null) return 1e5;
|
|
return node.kind === 'stub' ? node.depth + 0.5 : node.depth;
|
|
}
|
|
|
|
function anchorOf(lay, end, asParent) {
|
|
const p = lay.pos[end.node];
|
|
const d = lay.drawings[end.node];
|
|
// Power gear sits in a rail on the right: its cable comes in from the left
|
|
if (p.rail) return { x: p.x + p.dx, y: p.y + d.h / 2, face: 'left' };
|
|
const a = d.anchor(end) || { x: d.w / 2, y: asParent ? d.h : 0, face: asParent ? 'bottom' : 'top' };
|
|
return { x: p.x + p.dx + a.x, y: p.y + a.y, face: a.face };
|
|
}
|
|
|
|
function layout(data, filters) {
|
|
const { nodes, links } = visibleGraph(data, filters);
|
|
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 = drawingFor(n, byIface);
|
|
drawings[n.id] = d;
|
|
const labelW = Math.min(Math.max(n.name.length * 25, 120), 900);
|
|
sizes[n.id] = { w: Math.max(d.w, labelW), h: d.h + LABEL_H };
|
|
});
|
|
|
|
const lay = { pos: {}, drawings, nodes, links, byId };
|
|
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));
|
|
|
|
let y = 0;
|
|
rows.forEach(row => {
|
|
const items = row.map(n => {
|
|
const xs = links.filter(l => (l.b.node === n.id && lay.pos[l.a.node]) || (l.a.node === n.id && lay.pos[l.b.node]))
|
|
.map(l => anchorOf(lay, l.b.node === n.id ? l.a : l.b, true).x);
|
|
return { n, w: sizes[n.id].w, bary: xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : null };
|
|
});
|
|
let last = 0;
|
|
items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; });
|
|
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((s, it) => s + (it.bary - (it.x + it.w / 2)), 0) / items.length;
|
|
const rowH = Math.max(...items.map(it => sizes[it.n.id].h));
|
|
items.forEach(it => {
|
|
lay.pos[it.n.id] = { x: it.x + shift, y, w: it.w, dx: (it.w - drawings[it.n.id].w) / 2 };
|
|
});
|
|
y += rowH + ROW_GAP;
|
|
});
|
|
|
|
// Power gear: a rail down the right, each unit level with the port that feeds it
|
|
const placed = Object.values(lay.pos);
|
|
const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0;
|
|
const top = placed.length ? Math.min(...placed.map(p => p.y)) : 0;
|
|
const feederY = n => {
|
|
const l = links.find(k => k.b.node === n.id && lay.pos[k.a.node]);
|
|
return l ? anchorOf(lay, l.a, true).y : Infinity;
|
|
};
|
|
let railY = top;
|
|
nodes.filter(n => n.power).map(n => ({ n, f: feederY(n) }))
|
|
.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 y = Math.max(want, railY);
|
|
lay.pos[n.id] = { x: maxX + RAIL_GAP, y, w: sizes[n.id].w, dx: 0, rail: true };
|
|
railY = y + sizes[n.id].h + 120;
|
|
});
|
|
|
|
const all = Object.entries(lay.pos);
|
|
lay.bounds = {
|
|
x0: Math.min(...all.map(([, p]) => p.x)) - 80,
|
|
y0: Math.min(...all.map(([, p]) => p.y)) - 80,
|
|
x1: Math.max(...all.map(([, p]) => p.x + p.w)) + 80,
|
|
y1: Math.max(...all.map(([id, p]) => p.y + sizes[id].h)) + 80,
|
|
};
|
|
return lay;
|
|
}
|
|
|
|
// ── Rendering ───────────────────────────────────────────────────────────
|
|
// A cable leaves each end square to the face it plugs into: up or down from a faceplate, or
|
|
// sideways into the power rail
|
|
function cablePath(A, B) {
|
|
const dir = (P, Q) => {
|
|
if (P.face === 'left') return [-1, 0];
|
|
if (P.face === 'right') return [1, 0];
|
|
return [0, Q.y >= P.y ? 1 : -1];
|
|
};
|
|
const c = Math.min(900, Math.max(90, Math.hypot(B.x - A.x, B.y - A.y) * 0.4));
|
|
const [ax, ay] = dir(A, B);
|
|
const [bx, by] = dir(B, A);
|
|
const c1 = { x: A.x + ax * c, y: A.y + ay * c };
|
|
const c2 = { x: B.x + bx * c, y: B.y + by * c };
|
|
const mid = { x: (A.x + 3 * c1.x + 3 * c2.x + B.x) / 8, y: (A.y + 3 * c1.y + 3 * c2.y + B.y) / 8 };
|
|
return { d: `M${A.x} ${A.y} C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${B.x} ${B.y}`, mid };
|
|
}
|
|
|
|
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 A = anchorOf(lay, link.a, true);
|
|
const B = anchorOf(lay, link.b, false);
|
|
const { d, mid } = cablePath(A, B);
|
|
const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)];
|
|
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.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>`;
|
|
}
|
|
const plugs = ['dac', 'fibre', 'sfp'].includes(link.medium)
|
|
? `<circle class="tp-plug" cx="${A.x}" cy="${A.y}" r="9"/><circle class="tp-plug" cx="${B.x}" cy="${B.y}" r="9"/>`
|
|
: `<rect class="tp-boot" x="${A.x - 9}" y="${A.y - 9}" width="18" height="18" rx="3"/><rect class="tp-boot" x="${B.x - 9}" y="${B.y - 9}" width="18" height="18" rx="3"/>`;
|
|
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 sub = node.kind === 'stub'
|
|
? [node.ip ? '' : (node.mac || ''), `${node.switch} port ${node.port_idx}`].filter(Boolean).join(' · ')
|
|
: [node.kind === 'host' ? node.status : (node.model || node.kind), node.kind !== 'host' && node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
|
|
const ring = node.severity
|
|
? `<rect class="tp-ring tp-ring-${esc(node.severity)}" x="${p.dx - 18}" y="-18" width="${d.w + 36}" height="${d.h + 36}" rx="16"/>
|
|
<g class="tp-badge tp-badge-${esc(node.severity)}" transform="translate(${p.dx + d.w + 10} -14)"><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 = 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 = href ? '</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>
|
|
<rect class="tp-node-hit" x="${p.dx - 10}" y="-10" width="${d.w + 20}" height="${d.h + LABEL_H}" rx="12"/>
|
|
${ring}
|
|
<g transform="translate(${p.dx} 0)">${d.svg}</g>
|
|
${node.kind === 'internet' ? '' : `<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${p.w / 2 - node.name.length * 12 - 34}" cy="${d.h + 46}" r="10"/>`}
|
|
${node.kind === 'internet' ? '' : `<text class="tp-name" x="${p.w / 2}" y="${d.h + 58}" text-anchor="middle">${esc(node.name)}</text>`}
|
|
<text class="tp-sub" x="${p.w / 2}" y="${d.h + 96}" text-anchor="middle">${esc(sub)}</text>
|
|
${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(cb => { cb.checked = !!state.filters[cb.dataset.filter]; });
|
|
}
|
|
|
|
function render() {
|
|
const data = state.data;
|
|
if (!data) return;
|
|
state.layout = layout(data, state.filters);
|
|
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) {
|
|
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">✕</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;
|
|
|
|
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 link = e.target.closest('.tp-link');
|
|
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 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(cb => {
|
|
cb.addEventListener('change', () => {
|
|
state.filters[cb.dataset.filter] = cb.checked;
|
|
saveFilters();
|
|
if (cb.dataset.filter === 'stubs' || cb.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);
|
|
});
|