Lint / JS (eslint) (push) Successful in 17s
Lint / Python (flake8) (push) Successful in 2m2s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Python (flake8) (pull_request) Successful in 1m47s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (push) Successful in 1m57s
Security / Python Security (bandit) (pull_request) Successful in 1m54s
Test / Python Tests (pytest) (push) Successful in 2m26s
Test / Python Tests (pytest) (pull_request) Successful in 2m27s
- A bar above the diagram lists what needs attention: devices down, degraded or alerting, cables down or last seen elsewhere, mislabelled ports, cables with errors. Click one to zoom to it; a missing device is one entry, with where it was last seen. All clear says so. - Problem cables glow and are always drawn, whatever the filters; problem cards get a red or amber border. Healthy cables are drawn quietly. - Each server's slower second cable (its 1G management link) is hidden by default while healthy: "Server 1G links" shows them. That was most of the clutter. "10G only" is gone, since this covers it. - Power gear is off by default until the power map (part 2) gives the rail something to show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1219 lines
54 KiB
JavaScript
1219 lines
54 KiB
JavaScript
'use strict';
|
|
/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */
|
|
// Topology page: a card per device with its real faceplate or chassis, and each cable leaving the
|
|
// exact port it is plugged into. Data comes from /api/topology (topology.build_graph).
|
|
|
|
const esc = s => lt.escHtml(s == null ? '' : String(s));
|
|
const PER_MM = 1100 / 482.6;
|
|
const NODE_GAP = 90;
|
|
const REFRESH_MS = 30000;
|
|
const FILTER_KEY = 'gandalf.topology.filters';
|
|
const DEFAULT_FILTERS = { stubs: 'grouped', power: false, 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.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);
|
|
}
|
|
}
|
|
|
|
// ── Cards ───────────────────────────────────────────────────────────────
|
|
// Every device is a card of the same height: its name and state on top, and below that its real
|
|
// faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card
|
|
// edge right above or below the port they are plugged into.
|
|
const CARD_W = 640;
|
|
const CARD_SMALL_W = 460;
|
|
const CARD_H = 300;
|
|
const BAND_Y = 132;
|
|
const BAND_H = 140;
|
|
const NIC_W = 104;
|
|
|
|
function fitArt(art, x, y, w, h) {
|
|
const k = Math.min(w / art.w, h / art.h);
|
|
return { k, ox: x + (w - art.w * k) / 2, oy: y + (h - art.h * k) / 2 };
|
|
}
|
|
|
|
function nicSockets(node, linksByIface, x0) {
|
|
const nics = (node.interfaces || []).slice();
|
|
Object.keys(linksByIface).forEach(iface => {
|
|
if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' });
|
|
});
|
|
const pos = {};
|
|
const svg = nics.map((nic, i) => {
|
|
const link = linksByIface[nic.name];
|
|
const cx = x0 + i * NIC_W + NIC_W / 2;
|
|
const cy = BAND_Y + 40;
|
|
pos[nic.name] = { x: cx, y: cy - 20 };
|
|
const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium);
|
|
let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down');
|
|
if (link && !link.up) st = 'down';
|
|
const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`;
|
|
const shape = sfp
|
|
? `<rect class="tp-nic-sfp" x="${cx - 30}" y="${cy - 20}" width="60" height="38" rx="3"/><rect class="tp-nic-sfp-in" x="${cx - 22}" y="${cy - 13}" width="44" height="24" rx="2"/>`
|
|
: `<path class="tp-nic-rj45" d="M${cx - 28} ${cy - 12} h16 v-8 h24 v8 h16 v32 h-56 Z"/>`;
|
|
const label = nic.name.length > 9 ? `${nic.name.slice(0, 8)}…` : nic.name;
|
|
return `<g class="tp-nic tp-nic-${st}${nic.alert ? ` tp-nic-alert-${esc(nic.alert)}` : ''}"><title>${esc(title)}</title>
|
|
${shape}<circle class="tp-nic-led" cx="${cx + 22}" cy="${cy + 28}" r="5"/>
|
|
<text class="tp-nic-name" x="${cx}" y="${cy + 70}" text-anchor="middle">${esc(label)}</text></g>`;
|
|
}).join('');
|
|
return { svg, pos, w: nics.length * NIC_W };
|
|
}
|
|
|
|
function cardFor(node, linksByIface) {
|
|
let w, band, anchorAt;
|
|
if (node.kind === 'host') {
|
|
const front = hostFront(node);
|
|
const sockets0 = nicSockets(node, linksByIface, 0);
|
|
const frontW = front ? 220 : 0;
|
|
w = Math.max(CARD_W, 56 + frontW + (front ? 24 : 0) + sockets0.w, Math.min(node.name.length * 27 + 130, 980));
|
|
const sx = 28 + (front ? frontW + 24 : (w - 56 - sockets0.w) / 2);
|
|
const sockets = nicSockets(node, linksByIface, sx);
|
|
let art = '';
|
|
if (front) {
|
|
const f = fitArt(front, 28, BAND_Y, frontW, BAND_H);
|
|
art = `<g transform="translate(${f.ox} ${f.oy}) scale(${f.k})">${front.svg}</g>`;
|
|
}
|
|
band = `${art}${sockets.svg}`;
|
|
anchorAt = end => (end.iface && sockets.pos[end.iface]) || null;
|
|
} else {
|
|
const small = ['internet', 'modem', 'stub', 'group', 'ap', 'strip'].includes(node.kind);
|
|
w = Math.max(small ? CARD_SMALL_W : CARD_W, Math.min(node.name.length * 27 + 130, 980));
|
|
const art = artFor(node);
|
|
const f = fitArt(art, 28, BAND_Y, w - 56, BAND_H);
|
|
band = `<g transform="translate(${f.ox} ${f.oy}) scale(${f.k})">${art.svg}</g>`;
|
|
anchorAt = end => {
|
|
const a = art.anchor(end);
|
|
return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null;
|
|
};
|
|
}
|
|
const maxChars = Math.floor((w - 130) / 27);
|
|
const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name;
|
|
let sub;
|
|
if (node.kind === 'group') sub = `on ${node.switch}`;
|
|
else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`);
|
|
else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`;
|
|
else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status;
|
|
else sub = [node.model || node.kind, node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
|
|
const svg = `<rect class="tp-card" width="${w}" height="${CARD_H}" rx="18"/>
|
|
<rect class="tp-card-stripe tp-stripe-${esc(node.status)}" x="0" y="18" width="8" height="${CARD_H - 36}" rx="4"/>
|
|
<text class="tp-card-name" x="34" y="66">${esc(name)}</text>
|
|
<text class="tp-card-sub" x="34" y="108">${esc(sub)}</text>
|
|
<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${w - 42}" cy="52" r="13"/>
|
|
<rect class="tp-band" x="20" y="${BAND_Y - 8}" width="${w - 40}" height="${BAND_H + 16}" rx="10"/>
|
|
${band}`;
|
|
return { w, h: CARD_H, svg, anchorAt };
|
|
}
|
|
|
|
// ── Layout ──────────────────────────────────────────────────────────────
|
|
// Rows top down by hops from the internet. Cables are routed like cable management: each drops
|
|
// straight down from its port into the tray above the row it is going to, runs along its own lane
|
|
// there, then drops straight into its socket. Lanes never share, so cables never lie on top of one
|
|
// another and only ever cross at right angles. Power gear hangs in a rail on the right.
|
|
const TRAY_PAD = 70;
|
|
const LANE = 30;
|
|
const TRAY_MIN = 200;
|
|
const CORRIDOR_PAD = 60;
|
|
const CORNER = 26;
|
|
|
|
// Unknown devices on one switch are grouped into a single block with one bundled cable, unless
|
|
// the viewer opens that switch's group (or shows them all)
|
|
function visibleGraph(data, filters, expanded) {
|
|
const mode = filters.stubs;
|
|
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>`;
|
|
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">✕</button>
|
|
</div>${warn}<table class="tp-panel-table"><tbody>${rows.join('')}</tbody></table>`;
|
|
panel.hidden = false;
|
|
state.selected = link.id;
|
|
document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected'));
|
|
document.querySelectorAll(`.tp-link[data-link="${CSS.escape(link.id)}"]`).forEach(el => el.classList.add('tp-selected'));
|
|
}
|
|
|
|
function hidePanel() {
|
|
document.getElementById('tp-panel').hidden = true;
|
|
state.selected = null;
|
|
document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected'));
|
|
}
|
|
|
|
// ── Path to the internet ────────────────────────────────────────────────
|
|
function upstream(id) {
|
|
const links = state.layout.links;
|
|
const nodes = new Set([id]);
|
|
const hot = new Set();
|
|
const queue = [id];
|
|
while (queue.length) {
|
|
const cur = queue.shift();
|
|
links.forEach(l => {
|
|
if (l.b.node === cur && !l.ghost) {
|
|
hot.add(l.id);
|
|
if (!nodes.has(l.a.node)) {
|
|
nodes.add(l.a.node);
|
|
queue.push(l.a.node);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return { nodes, hot };
|
|
}
|
|
|
|
function highlight(id) {
|
|
const canvas = document.getElementById('tp-canvas');
|
|
document.querySelectorAll('.tp-hot').forEach(el => el.classList.remove('tp-hot'));
|
|
if (!id || !state.layout) {
|
|
canvas.classList.remove('tp-tracing');
|
|
return;
|
|
}
|
|
const { nodes, hot } = upstream(id);
|
|
nodes.forEach(n => document.querySelector(`.tp-node[data-node="${CSS.escape(n)}"]`)?.classList.add('tp-hot'));
|
|
hot.forEach(l => document.querySelectorAll(`.tp-link[data-link="${CSS.escape(l)}"]`).forEach(el => el.classList.add('tp-hot')));
|
|
canvas.classList.add('tp-tracing');
|
|
}
|
|
|
|
// ── Pan and zoom ────────────────────────────────────────────────────────
|
|
const K_MIN = 0.03;
|
|
const K_MAX = 3;
|
|
|
|
// On a narrow screen the diagram is much wider than tall: size the canvas to it instead of
|
|
// leaving most of a tall canvas empty
|
|
function sizeCanvas() {
|
|
const canvas = document.getElementById('tp-canvas');
|
|
if (!state.layout || canvas.clientWidth > 700) {
|
|
canvas.style.height = '';
|
|
return;
|
|
}
|
|
const { x0, y0, x1, y1 } = state.layout.bounds;
|
|
const want = canvas.clientWidth * (y1 - y0) / (x1 - x0);
|
|
canvas.style.height = `${Math.round(Math.max(440, Math.min(want + 24, window.innerHeight * 0.75)))}px`;
|
|
}
|
|
|
|
function fit() {
|
|
if (!state.layout) return;
|
|
const canvas = document.getElementById('tp-canvas');
|
|
const { x0, y0, x1, y1 } = state.layout.bounds;
|
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
|
const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(cw / (x1 - x0), ch / (y1 - y0))));
|
|
state.view = { k, tx: (cw - (x1 - x0) * k) / 2 - x0 * k, ty: (ch - (y1 - y0) * k) / 2 - y0 * k };
|
|
applyView();
|
|
}
|
|
|
|
function zoomAt(factor, px, py) {
|
|
const v = state.view;
|
|
const k = Math.max(K_MIN, Math.min(K_MAX, v.k * factor));
|
|
const wx = (px - v.tx) / v.k, wy = (py - v.ty) / v.k;
|
|
state.view = { k, tx: px - wx * k, ty: py - wy * k };
|
|
applyView();
|
|
}
|
|
|
|
function zoomCentre(factor) {
|
|
const canvas = document.getElementById('tp-canvas');
|
|
zoomAt(factor, canvas.clientWidth / 2, canvas.clientHeight / 2);
|
|
}
|
|
|
|
function initPanZoom() {
|
|
const canvas = document.getElementById('tp-canvas');
|
|
const svg = document.getElementById('tp-svg');
|
|
const pointers = new Map();
|
|
let drag = null;
|
|
let pinch = null;
|
|
let suppressClick = false;
|
|
|
|
const local = e => {
|
|
const r = canvas.getBoundingClientRect();
|
|
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
};
|
|
|
|
svg.addEventListener('pointerdown', e => {
|
|
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
pointers.set(e.pointerId, local(e));
|
|
if (pointers.size === 1) {
|
|
drag = { start: local(e), view: Object.assign({}, state.view), id: e.pointerId, moved: false };
|
|
} else if (pointers.size === 2) {
|
|
const [p, q] = [...pointers.values()];
|
|
pinch = { dist: Math.hypot(p.x - q.x, p.y - q.y), k: state.view.k };
|
|
drag = null;
|
|
}
|
|
});
|
|
|
|
svg.addEventListener('pointermove', e => {
|
|
if (!pointers.has(e.pointerId)) return;
|
|
pointers.set(e.pointerId, local(e));
|
|
if (pinch && pointers.size === 2) {
|
|
const [p, q] = [...pointers.values()];
|
|
const dist = Math.hypot(p.x - q.x, p.y - q.y);
|
|
zoomAt((pinch.k * dist / pinch.dist) / state.view.k, (p.x + q.x) / 2, (p.y + q.y) / 2);
|
|
suppressClick = true;
|
|
return;
|
|
}
|
|
if (!drag || drag.id !== e.pointerId) return;
|
|
const pt = local(e);
|
|
const dx = pt.x - drag.start.x, dy = pt.y - drag.start.y;
|
|
if (!drag.moved && Math.hypot(dx, dy) < 5) return;
|
|
if (!drag.moved) {
|
|
drag.moved = true;
|
|
svg.setPointerCapture(e.pointerId);
|
|
canvas.classList.add('tp-dragging');
|
|
}
|
|
state.view = { k: drag.view.k, tx: drag.view.tx + dx, ty: drag.view.ty + dy };
|
|
applyView();
|
|
});
|
|
|
|
const end = e => {
|
|
pointers.delete(e.pointerId);
|
|
if (drag && drag.id === e.pointerId && drag.moved) suppressClick = true;
|
|
if (pointers.size < 2) pinch = null;
|
|
if (!pointers.size) drag = null;
|
|
canvas.classList.remove('tp-dragging');
|
|
};
|
|
svg.addEventListener('pointerup', end);
|
|
svg.addEventListener('pointercancel', end);
|
|
|
|
// A drag that ends over a device is not a click on it
|
|
svg.addEventListener('click', e => {
|
|
if (suppressClick) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
suppressClick = false;
|
|
}
|
|
}, true);
|
|
|
|
svg.addEventListener('wheel', e => {
|
|
e.preventDefault();
|
|
const pt = local(e);
|
|
zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), pt.x, pt.y);
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('keydown', e => {
|
|
if (e.target.closest('input, select, textarea, .tp-panel')) return;
|
|
if (e.key === '+' || e.key === '=') { zoomCentre(1.25); e.preventDefault(); }
|
|
else if (e.key === '-' || e.key === '_') { zoomCentre(0.8); e.preventDefault(); }
|
|
else if (e.key === '0') { fit(); e.preventDefault(); }
|
|
else if (e.key === 'Escape') hidePanel();
|
|
});
|
|
}
|
|
|
|
// ── Events ──────────────────────────────────────────────────────────────
|
|
function initEvents() {
|
|
const svg = document.getElementById('tp-svg');
|
|
svg.addEventListener('click', e => {
|
|
const port = e.target.closest('[data-action="select-port"]');
|
|
if (port) {
|
|
e.preventDefault();
|
|
window.location.href = `${deviceHref(port.dataset.switch)}#port-${encodeURIComponent(port.dataset.portIdx)}`;
|
|
return;
|
|
}
|
|
const group = e.target.closest('[data-expand]');
|
|
const link = e.target.closest('.tp-link');
|
|
if (group || (link && link.dataset.link.startsWith('bundle:'))) {
|
|
state.expanded.add(group ? group.dataset.expand : link.dataset.link.slice('bundle:'.length));
|
|
render();
|
|
return;
|
|
}
|
|
if (link) {
|
|
const found = state.data.links.find(l => l.id === link.dataset.link);
|
|
if (found) showPanel(found);
|
|
return;
|
|
}
|
|
if (!e.target.closest('.tp-node')) hidePanel();
|
|
});
|
|
svg.addEventListener('keydown', e => {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
const group = e.target.closest('[data-expand]');
|
|
if (group) {
|
|
e.preventDefault();
|
|
state.expanded.add(group.dataset.expand);
|
|
render();
|
|
return;
|
|
}
|
|
const link = e.target.closest('.tp-link');
|
|
if (link) {
|
|
e.preventDefault();
|
|
const found = state.data.links.find(l => l.id === link.dataset.link);
|
|
if (found) showPanel(found);
|
|
}
|
|
});
|
|
const trace = e => {
|
|
const node = e.target.closest('.tp-node');
|
|
highlight(node ? node.dataset.node : null);
|
|
};
|
|
svg.addEventListener('mouseover', trace);
|
|
svg.addEventListener('focusin', trace);
|
|
svg.addEventListener('mouseleave', () => highlight(null));
|
|
svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); });
|
|
|
|
document.getElementById('tp-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);
|
|
});
|
|
}
|
|
|
|
// ── 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);
|
|
});
|