'use strict'; /* global TOPO_CAN_EDIT, FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */ // Topology page: a card per device with its real faceplate or chassis, and each cable leaving the // exact port it is plugged into. Data comes from /api/topology (topology.build_graph). const esc = s => lt.escHtml(s == null ? '' : String(s)); const PER_MM = 1100 / 482.6; const NODE_GAP = 90; const REFRESH_MS = 30000; const FILTER_KEY = 'gandalf.topology.filters'; const DEFAULT_FILTERS = { stubs: 'grouped', power: true, traffic: true, mgmt: false, alerts: false }; const STUB_MODES = ['grouped', 'all', 'none']; const state = { data: null, layout: null, view: { k: 1, tx: 0, ty: 0 }, fitted: false, filters: loadFilters(), expanded: new Set(), // switches whose unknown devices are shown one by one selected: null, }; function loadFilters() { try { const saved = Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}')); if (!STUB_MODES.includes(saved.stubs)) saved.stubs = saved.stubs === false ? 'none' : 'grouped'; delete saved.tenG; return saved; } catch (_) { return Object.assign({}, DEFAULT_FILTERS); } } function saveFilters() { try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.filters)); } catch (_) { /* private mode */ } } // ── Formatting ────────────────────────────────────────────────────────── function speedText(mbps) { if (!mbps) return ''; return mbps >= 1000 ? `${+(mbps / 1000).toFixed(1)}G` : `${mbps}M`; } function rateText(bytesPerSec) { const bits = (bytesPerSec || 0) * 8; if (bits >= 1e9) return `${(bits / 1e9).toFixed(2)} Gb/s`; if (bits >= 1e6) return `${(bits / 1e6).toFixed(1)} Mb/s`; if (bits >= 1e3) return `${(bits / 1e3).toFixed(0)} kb/s`; return `${bits.toFixed(0)} b/s`; } const MEDIUM_TEXT = { dac: 'DAC (twinax)', fibre: 'Fibre', sfp: 'SFP+ module', copper: 'Copper' }; function deviceHref(name) { return `/device/${encodeURIComponent(name)}`; } function nodeHref(node) { if (node.kind === 'group') return ''; if (node.kind === 'stub') return `${deviceHref(node.switch)}#port-${encodeURIComponent(node.port_idx)}`; if (node.kind === 'internet') return ''; return deviceHref(node.name); } // ── Problems ──────────────────────────────────────────────────────────── // Anything that needs a look: drawn in full colour, always shown whatever the filters, and listed // above the diagram. Healthy cables are drawn quietly. function linkProblem(link) { if (link.bundle) return ''; if (link.ghost) return 'ghost'; if (!link.up) return 'down'; if (link.name_mismatch) return 'mismatch'; if ((link.errors || 0) > 0) return 'errors'; return ''; } function nodeProblem(node) { if (['stub', 'group', 'internet'].includes(node.kind) && !node.alert_count) return node.kind === 'internet' && node.status === 'down' ? 'down' : ''; if (node.status === 'down' || node.status === 'degraded') return node.status; return node.alert_count ? 'alert' : ''; } function endText(e, byId) { const n = byId[e.node]; const name = n ? n.name : e.node; if (e.port_idx != null) return `${name} port ${e.port_idx}`; return e.iface ? `${name} ${e.iface}` : name; } function problemsOf(data) { const byId = Object.fromEntries(data.nodes.map(n => [n.id, n])); const out = []; data.nodes.forEach(n => { const p = nodeProblem(n); if (!p) return; const bits = []; if (n.status === 'down' || n.status === 'degraded') bits.push(n.status); if (n.alert_count) bits.push(`${n.alert_count} alert${n.alert_count > 1 ? 's' : ''}`); out.push({ kind: 'node', id: n.id, severity: n.status === 'down' || n.severity === 'critical' ? 'bad' : 'warn', text: `${n.name}: ${bits.join(', ')}` }); }); (data.power || []).forEach(src => src.outlets.forEach(o => { if (o.on || !o.target) return; const down = (byId[o.target] || {}).status === 'down'; out.push({ kind: 'node', id: o.target, severity: down ? 'bad' : 'warn', text: `${src.node} outlet ${o.index} is off, and it feeds ${o.target}` }); })); data.links.forEach(l => { const p = linkProblem(l); if (!p) return; // A missing device is one problem, not two: say where it was last seen on its own entry const listed = out.find(o => o.kind === 'node' && o.id === l.b.node); if (p === 'ghost' && listed) { listed.text += `, last seen on ${endText(l.a, byId)}`; return; } const host = byId[l.b.node] && byId[l.b.node].kind === 'stub' ? `${byId[l.b.node].name} on ${endText(l.a, byId)}` : null; let text; if (p === 'ghost') text = `${byId[l.b.node] ? byId[l.b.node].name : l.b.node} not seen, last on ${endText(l.a, byId)}`; else if (p === 'down') text = `Cable down: ${host || `${endText(l.a, byId)} to ${endText(l.b, byId)}`}`; else if (p === 'mismatch') { const m = l.name_mismatch; const end = m.end === 'b' ? l.b : l.a; text = `${endText(end, byId)} is named after ${m.named}, but ${m.actual} is plugged in`; } else text = `Errors on ${endText(l.a, byId)} to ${endText(l.b, byId)} (${l.errors}/s)`; out.push({ kind: 'link', id: l.id, severity: p === 'mismatch' || p === 'errors' ? 'warn' : 'bad', text }); }); return out; } function speedClass(mbps) { if (mbps >= 10000) return 'tp-s-10g'; if (mbps >= 1000) return 'tp-s-1g'; return 'tp-s-100m'; } function mediumClass(link) { if (link.wan && link.medium === 'fibre') return 'tp-m-wanfibre'; return `tp-m-${link.medium || 'copper'}`; } // ── Device drawings ───────────────────────────────────────────────────── // The shared drawings size themselves to their page with CSS (width: 100%), which would override // the width and height given here, so they are drawn under a class of our own. function nested(svg) { return svg.replace(/^(\s* {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 `${portTitle(p, idx)} ${idx}`; }).join(''); return { w, h, svg: ` ${esc(node.model || 'switch')}${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: ` INTERNET`, 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 `${name}`; }).join(''); return { w, h, svg: ` ${leds} ${esc((node.model || '').split('-')[0])}`, // 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: ` `, 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 `${esc(title)} `; }).join(''); return { w: Math.max(w, 90 + n * 95), h, svg: `${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 ``; }).join(''); return { w, h, svg: `${jacks} click to show`, anchor: () => ({ x: w / 2, y: 4, face: 'top' }), }; } function boxDrawing(node, text) { const w = 460, h = 150; return { w, h, svg: ` ${esc(text)}`, anchor: () => null, }; } function stubDrawing() { const w = 160, h = 100; return { w, h, svg: ` ?`, 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('${gear.draw(ctx)}` }; } if (CHASSIS_FACEPLATES[node.chassis]) return faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100); return null; } // The picture inside a card, for everything but hosts function artFor(node) { const spec = FACEPLATES[node.model]; switch (node.kind) { case 'internet': return internetDrawing(); case 'modem': return modemDrawing(node); case 'stub': return stubDrawing(); case 'group': return groupDrawing(node); case 'ap': return apDrawing(node); case 'strip': return stripDrawing(node); default: if (spec && DESK_FACEPLATES[node.model]) return deskFaceplate(node); if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups', crop: !node.power }); if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node); return boxDrawing(node, node.model || node.kind); } } // ── Power ─────────────────────────────────────────────────────────────── function wattText(w) { return w == null ? '' : `${w >= 100 ? Math.round(w) : (+w).toFixed(1)} W`; } function powerOf(name) { return ((state.data && state.data.power) || []).find(p => p.node === name); } // The outlets worth listing on a power card: those feeding something, drawing power, or off function listedOutlets(src) { return src.outlets.filter(o => o.target || (o.power_w || 0) >= 0.5 || !o.on); } const ROW_H = 46; // ── Cards ─────────────────────────────────────────────────────────────── // Every device is a card of the same height: its name and state on top, and below that its real // faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card // edge right above or below the port they are plugged into. const CARD_W = 640; const CARD_SMALL_W = 460; const CARD_H = 300; const BAND_Y = 132; const BAND_H = 140; const NIC_W = 104; function fitArt(art, x, y, w, h) { const k = Math.min(w / art.w, h / art.h); return { k, ox: x + (w - art.w * k) / 2, oy: y + (h - art.h * k) / 2 }; } function nicSockets(node, linksByIface, x0) { const nics = (node.interfaces || []).slice(); Object.keys(linksByIface).forEach(iface => { if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' }); }); const pos = {}; const svg = nics.map((nic, i) => { const link = linksByIface[nic.name]; const cx = x0 + i * NIC_W + NIC_W / 2; const cy = BAND_Y + 40; pos[nic.name] = { x: cx, y: cy - 20 }; const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium); let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down'); if (link && !link.up) st = 'down'; const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`; const shape = sfp ? `` : ``; const label = nic.name.length > 9 ? `${nic.name.slice(0, 8)}…` : nic.name; return `${esc(title)} ${shape} ${esc(label)}`; }).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 = `${front.svg}`; } 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 = `${art.svg}`; anchorAt = end => { const a = art.anchor(end); return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null; }; } // Power gear lists its outlets under the picture: what each feeds and what it draws let h = CARD_H; let rowsSvg = ''; const rows = {}; const src = node.power ? powerOf(node.name) : null; if (src) { const list = listedOutlets(src); rowsSvg = list.map((o, i) => { const y = CARD_H + i * ROW_H; rows[o.index] = y + ROW_H / 2; const what = o.target || (o.match === 'cleared' ? 'nothing' : 'unassigned'); const cls = `tp-outlet-row${o.on ? '' : ' tp-outlet-off'}${o.target ? '' : ' tp-outlet-free'}`; const tip = `${node.name} outlet ${o.index}${o.name ? ` "${o.name}"` : ''} · ${o.on ? 'on' : 'off'}${o.power_w != null ? ` · ${wattText(o.power_w)}` : ''} · feeds ${what}${o.match === 'saved' ? ' (set here)' : o.match === 'name' ? ' (from its name)' : o.match === 'mac' ? ' (from the MAC in its name)' : ''}${TOPO_CAN_EDIT ? ' · click to change' : ''}`; return ` ${esc(tip)} ${o.index} ${esc(what.length > 22 ? `${what.slice(0, 21)}…` : what)} ${o.on ? esc(wattText(o.power_w)) : 'off'} `; }).join(''); h = CARD_H + list.length * ROW_H + (list.length ? 16 : 0); } // What feeds this device, as a badge by its name let badge = ''; if ((node.power_in || []).length) { const off = node.power_in.some(f => !f.on); const watts = node.power_in.reduce((t, f) => t + (f.power_w || 0), 0); const tip = node.power_in.map(f => `${f.source} outlet ${f.outlet}${f.on ? '' : ' (off)'}${f.power_w != null ? ` · ${wattText(f.power_w)}` : ''}`).join('; '); badge = `${esc(`Fed by ${tip}`)}⚡ ${off ? 'off' : esc(wattText(watts) || 'fed')}`; } const maxChars = Math.floor((w - 130) / 27); const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name; let sub; if (node.kind === 'group') sub = `on ${node.switch}`; else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`); else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`; else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status; else sub = [node.model || node.kind, src && src.power_w != null ? `${wattText(src.power_w)} total` : '', node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · '); // The second line shares its space with the power badge const subMax = Math.floor((w - (badge ? 250 : 70)) / 16.8); if (sub.length > subMax) sub = `${sub.slice(0, subMax - 1)}…`; const svg = ` ${esc(name)} ${esc(sub)} ${badge} ${band}${rowsSvg}`; return { w, h, svg, anchorAt, rows }; } // ── Layout ────────────────────────────────────────────────────────────── // Rows top down by hops from the internet. Cables are routed like cable management: each drops // straight down from its port into the tray above the row it is going to, runs along its own lane // there, then drops straight into its socket. Lanes never share, so cables never lie on top of one // another and only ever cross at right angles. Power gear hangs in a rail on the right. const TRAY_PAD = 70; const LANE = 30; const TRAY_MIN = 200; const CORRIDOR_PAD = 60; const CORNER = 26; // Unknown devices on one switch are grouped into a single block with one bundled cable, unless // the viewer opens that switch's group (or shows them all) function visibleGraph(data, filters, expanded) { const mode = filters.stubs; const all = Object.fromEntries(data.nodes.map(n => [n.id, n])); let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power)); let links = data.links.slice(); if (!filters.mgmt) { // A server's slower second link (its 1G management cable) is hidden while it is healthy and // the server has a faster one; a problem cable is always drawn links = links.filter(l => { const host = [l.a, l.b].find(e => (all[e.node] || {}).kind === 'host'); if (!host || linkProblem(l)) return true; const faster = data.links.some(o => o !== l && [o.a.node, o.b.node].includes(host.node) && (o.speed_mbps || 0) > (l.speed_mbps || 0)); return !faster; }); } if (mode === 'grouped') { const bySwitch = {}; nodes.filter(n => n.kind === 'stub').forEach(n => (bySwitch[n.switch] = bySwitch[n.switch] || []).push(n)); Object.entries(bySwitch).forEach(([sw, stubs]) => { if (stubs.length < 2 || expanded.has(sw)) return; const ids = new Set(stubs.map(n => n.id)); const cables = links.filter(l => ids.has(l.b.node)); const id = `group:${sw}`; nodes = nodes.filter(n => !ids.has(n.id)).concat([{ id, name: `${stubs.length} unknown devices`, kind: 'group', status: 'up', depth: stubs[0].depth, switch: sw, members: stubs, alerts: [], alert_count: 0, severity: '', power: false, }]); links = links.filter(l => !ids.has(l.b.node)).concat([{ id: `bundle:${sw}`, bundle: true, count: cables.length, ghost: false, wan: false, name_mismatch: null, a: { node: sw, port_idx: null, port_name: '', iface: '', ports: cables.map(l => l.a.port_idx) }, b: { node: id, port_idx: null, port_name: '', iface: '' }, up: cables.some(l => l.up), medium: 'copper', speed_mbps: Math.max(...cables.map(l => l.speed_mbps || 0)), a_to_b_bps: cables.reduce((t, l) => t + (l.a_to_b_bps || 0), 0), b_to_a_bps: cables.reduce((t, l) => t + (l.b_to_a_bps || 0), 0), }]); }); } const ids = new Set(nodes.map(n => n.id)); return { nodes, links: links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)) }; } // The internet, modem and gateway share the top row, left to right; then a row per hop, with // unknown devices beside the gear on their switch; servers along the bottom function rowKey(node) { if (node.kind === 'host') return 1e6; if (node.depth == null) return 1e5; if (['internet', 'modem', 'router'].includes(node.kind) && node.depth <= 2) return 0; return node.depth; } // Where a cable meets a card: on its bottom edge (leaving) or top edge (arriving), right above or // below the port it is plugged into, which is given as `port` for the short tail drawn to it function anchorOf(lay, end, asParent) { const p = lay.pos[end.node]; const d = lay.drawings[end.node]; if (p.rail) return { x: p.x, y: p.y + d.h / 2, face: 'left', port: null }; let a = null; if (end.ports) { // A bundle leaves from the middle of the ports it gathers const xs = end.ports.map(idx => d.anchorAt({ port_idx: idx })).filter(Boolean); if (xs.length) a = { x: xs.reduce((t, q) => t + q.x, 0) / xs.length, y: Math.max(...xs.map(q => q.y)) }; } else { a = d.anchorAt(end); } const x = p.x + (a ? a.x : d.w / 2); return { x, y: p.y + (asParent ? d.h : 0), face: asParent ? 'bottom' : 'top', port: a ? { x, y: p.y + a.y } : null }; } // Give each horizontal run in a tray a lane: fastest cables on the upper lanes, and two runs // share a lane only when they don't overlap function assignLanes(runs) { const lanes = []; runs.sort((a, b) => (b.speed - a.speed) || (a.x0 - b.x0)); runs.forEach(run => { let i = lanes.findIndex(lane => lane.every(o => run.x1 + 40 < o.x0 || run.x0 > o.x1 + 40)); if (i < 0) { lanes.push([]); i = lanes.length - 1; } lanes[i].push(run); run.lane = i; }); return lanes.length; } // Straight runs with rounded corners function roundedPath(points) { const pts = points.filter((p, i) => i === 0 || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 0.5); let d = `M${pts[0].x} ${pts[0].y}`; for (let i = 1; i < pts.length - 1; i++) { const [p, c, n] = [pts[i - 1], pts[i], pts[i + 1]]; const r = Math.min(CORNER, Math.hypot(c.x - p.x, c.y - p.y) / 2, Math.hypot(n.x - c.x, n.y - c.y) / 2); const u = (q, s) => ({ x: c.x + (q.x - c.x) / Math.hypot(q.x - c.x, q.y - c.y) * s, y: c.y + (q.y - c.y) / Math.hypot(q.x - c.x, q.y - c.y) * s }); const a = u(p, r), b = u(n, r); d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}`; } const last = pts[pts.length - 1]; return `${d} L${last.x} ${last.y}`; } function layout(data, filters, expanded) { const { nodes, links } = visibleGraph(data, filters, expanded); const byId = Object.fromEntries(nodes.map(n => [n.id, n])); const drawings = {}; const sizes = {}; nodes.forEach(n => { const byIface = {}; data.links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; })); const d = cardFor(n, byIface); drawings[n.id] = d; sizes[n.id] = { w: d.w, h: d.h }; }); const lay = { pos: {}, drawings, nodes, links, byId, routes: {} }; const rowsMap = new Map(); nodes.filter(n => !n.power).forEach(n => { const key = rowKey(n); if (!rowsMap.has(key)) rowsMap.set(key, []); rowsMap.get(key).push(n); }); const rows = [...rowsMap.keys()].sort((a, b) => a - b).map(k => rowsMap.get(k)); const rowOf = {}; rows.forEach((row, r) => row.forEach(n => { rowOf[n.id] = r; })); const ax = end => anchorOf(lay, end, true).x; // ── Across: each row in order of the ports feeding it, clear of cables passing down through it rows.forEach((row, r) => { const corridors = links .filter(l => rowOf[l.a.node] < r && rowOf[l.b.node] > r && lay.pos[l.a.node]) .map(l => ax(l.a)).sort((a, b) => a - b); const items = row.map(n => { // Line a device up under its fastest cable, so that one drops straight in const feeds = links.filter(l => l.b.node === n.id && lay.pos[l.a.node]) .sort((a, b) => (b.speed_mbps || 0) - (a.speed_mbps || 0)); const xs = feeds.length ? [ax(feeds[0].a)] : []; return { n, w: sizes[n.id].w, bary: xs.length ? xs[0] : null }; }); let last = 0; items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; }); if (r === 0) items.forEach(it => { it.bary = (it.n.depth || 0) * 1000; }); items.sort((a, b) => a.bary - b.bary || a.n.name.localeCompare(b.n.name)); let right = -Infinity; items.forEach(it => { it.x = Math.max(it.bary - it.w / 2, right + NODE_GAP); right = it.x + it.w; }); const shift = items.reduce((t, it) => t + (it.bary - (it.x + it.w / 2)), 0) / items.length; right = -Infinity; items.forEach(it => { let x = Math.max(it.x + shift, right + NODE_GAP); let moved = true; while (moved) { moved = false; for (const c of corridors) { if (c > x - CORRIDOR_PAD && c < x + it.w + CORRIDOR_PAD) { x = c + CORRIDOR_PAD; moved = true; } } } lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: 0 }; right = x + it.w; }); }); // ── Trays: the runs in each one, and their lanes const placed = Object.values(lay.pos); const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0; const railNodes = nodes.filter(n => n.power); const gutter0 = maxX + 160; const trays = Array.from({ length: rows.length + 1 }, () => []); const plan = {}; let gutterLane = 0; links.forEach(l => { if (!lay.pos[l.a.node]) return; const xa = ax(l.a); const toRail = byId[l.b.node].power; if (!toRail && rowOf[l.a.node] === rowOf[l.b.node]) { plan[l.id] = { across: true }; return; } let r, xb; if (toRail) { r = Math.min(rowOf[l.a.node] + 1, rows.length); xb = gutter0 + (gutterLane++) * LANE; } else { if (rowOf[l.b.node] == null) return; r = rowOf[l.b.node]; xb = anchorOf(lay, l.b, false).x; } const run = { id: l.id, x0: Math.min(xa, xb), x1: Math.max(xa, xb), speed: l.speed_mbps || 0, straight: Math.abs(xa - xb) < 2 }; plan[l.id] = { r, xa, xb, toRail, run }; if (!run.straight) trays[r].push(run); }); const trayH = trays.map(runs => Math.max(TRAY_MIN, TRAY_PAD * 2 + assignLanes(runs) * LANE)); // ── Down: rows and trays stacked const rowTop = []; let y = 0; rows.forEach((row, r) => { if (r > 0) y += trayH[r]; rowTop[r] = y; row.forEach(n => { lay.pos[n.id].y = y; }); y += Math.max(...row.map(n => sizes[n.id].h)); }); const trayTop = r => (r < rows.length ? rowTop[r] - trayH[r] : y); // Power rail: each unit level with the lane its cable arrives on const railX = gutter0 + Math.max(gutterLane, 1) * LANE + 260; let railY = rows.length ? rowTop[0] : 0; railNodes.map(n => { const l = links.find(k => k.b.node === n.id && plan[k.id]); const f = l ? trayTop(plan[l.id].r) + TRAY_PAD + (plan[l.id].run.lane || 0) * LANE : Infinity; return { n, f }; }).sort((a, b) => a.f - b.f || a.n.name.localeCompare(b.n.name)).forEach(({ n, f }) => { const want = Number.isFinite(f) ? f - drawings[n.id].h / 2 : railY; const top = Math.max(want, railY); lay.pos[n.id] = { x: railX, y: top, w: sizes[n.id].w, dx: 0, rail: true }; railY = top + sizes[n.id].h + 120; }); // ── Routes links.forEach(l => { const p = plan[l.id]; if (!p) return; if (p.across) { // Side by side in the top row: straight across from one card to the next const [pa, pb] = [lay.pos[l.a.node], lay.pos[l.b.node]]; const left = pa.x < pb.x; const A = { x: left ? pa.x + pa.w : pa.x, y: pa.y + CARD_H / 2, port: null }; const B = { x: left ? pb.x : pb.x + pb.w, y: pb.y + CARD_H / 2, port: null }; lay.routes[l.id] = { d: roundedPath([A, B]), mid: { x: (A.x + B.x) / 2, y: A.y - 40 }, A, B }; return; } const A = anchorOf(lay, l.a, true); const B = anchorOf(lay, l.b, false); const laneY = trayTop(p.r) + TRAY_PAD + (p.run.lane || 0) * LANE; let pts; if (p.toRail) pts = [A, { x: A.x, y: laneY }, { x: p.xb, y: laneY }, { x: p.xb, y: B.y }, B]; else if (p.run.straight) pts = [A, B]; else pts = [A, { x: A.x, y: laneY }, { x: B.x, y: laneY }, B]; // Traffic and "last seen" tags sit on the longest run let mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }; let best = -1; for (let i = 1; i < pts.length; i++) { const len = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); if (len > best) { best = len; mid = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 }; } } lay.routes[l.id] = { d: roundedPath(pts), mid, A, B }; }); const all = Object.entries(lay.pos); lay.bounds = { x0: Math.min(...all.map(([, q]) => q.x)) - 80, y0: Math.min(...all.map(([, q]) => q.y)) - 80, x1: Math.max(...all.map(([, q]) => q.x + q.w)) + 80, y1: Math.max(...all.map(([id, q]) => q.y + sizes[id].h)) + 80, }; return lay; } // ── Rendering ─────────────────────────────────────────────────────────── function linkTitle(link, byId) { const end = e => { const n = byId[e.node]; const name = n ? n.name : e.node; if (e.port_idx != null) return `${name} port ${e.port_idx}${e.port_name && e.port_name !== `Port ${e.port_idx}` ? ` "${e.port_name}"` : ''}`; return e.iface ? `${name} ${e.iface}` : name; }; const bits = [`${end(link.a)} to ${end(link.b)}`, speedText(link.speed_mbps), MEDIUM_TEXT[link.medium] || '']; if (link.ghost) bits.push(`last seen ${link.seen || ''}`); else if (!link.up) bits.push('down'); return bits.filter(Boolean).join(' · '); } function renderLink(link, lay) { const route = lay.routes[link.id]; if (!route) return { under: '', over: '' }; const { d, mid, A, B } = route; const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)]; if (link.bundle) cls.push('tp-bundle'); if (link.ghost) cls.push('tp-ghost'); else if (!link.up) cls.push('tp-down'); if (link.wan) cls.push('tp-wan'); const problem = linkProblem(link); if (problem) cls.push('tp-problem', `tp-problem-${problem}`); const busy = (link.a_to_b_bps || 0) + (link.b_to_a_bps || 0); const alerting = [link.a.node, link.b.node].some(id => (lay.byId[id] || {}).alert_count); if (alerting) cls.push('tp-touches-alert'); const flowDur = Math.max(0.6, 6 - Math.log10(Math.max(busy, 1)) * 0.8).toFixed(2); const flow = link.up && !link.ghost && busy > 1000 ? `` : ''; const label = link.bundle ? `${link.count} cables` : link.ghost ? `last seen` : (link.up ? ` ↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))}` : ''); 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 = `${esc(text)} !`; } // 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) ? `` : ``); const tail = E => (E.port ? `${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: ` ${esc(linkTitle(link, lay.byId))} ${problem ? `` : ''}${flow} `, over: ``, }; } function renderNode(node, lay) { const p = lay.pos[node.id]; const d = lay.drawings[node.id]; const href = nodeHref(node); const ring = node.severity ? ` ${node.alert_count}` : ''; 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' ? `` : (href ? `` : ``); const close = open.startsWith('' : ''; return ` ${open}${esc(label)} ${d.svg} ${ring} ${close} `; } 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 = `${cables.map(c => c.under).join('')} ${lay.nodes.map(n => renderNode(n, lay)).join('')} ${cables.map(c => c.over).join('')} `; 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 ? `${esc(n.name)}` : esc(n.name); let where = ''; if (end.port_idx != null) { where = `port ${esc(end.port_idx)}`; 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(`
${k}${v}
`); row('From', endHtml(link.a, byId)); row('To', endHtml(link.b, byId)); row('State', link.ghost ? `
not seen now · last seen ${esc(link.seen || 'unknown')}` : (link.up ? 'up' : 'down')); 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 ? `
S/N ${esc(link.sfp_serial)}` : '')); } 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 ? `${esc(link.errors)}/s` : 'none'); row('Drops', link.drops ? `${esc(link.drops)}/s` : 'none'); } if (link.cable) { const label = link.cable.label || `cable #${link.cable.id}`; row('Cutsheet', `${esc(label)}${link.cable.type ? ` · ${esc(link.cable.type)}` : ''}${link.cable.color ? ` · ${esc(link.cable.color)}` : ''}`); } else if (!link.wan) { row('Cutsheet', 'not documented'); } const warn = link.name_mismatch ? `

⚠ The port is named after ${esc(link.name_mismatch.named)}, but ${esc(link.name_mismatch.actual)} is plugged into it. Rename the port in UniFi, or check the cable.

` : ''; panel.innerHTML = `

Cable

${warn}${rows.join('')}
`; panel.hidden = false; state.selected = link.id; document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected')); document.querySelectorAll(`.tp-link[data-link="${CSS.escape(link.id)}"]`).forEach(el => el.classList.add('tp-selected')); } function hidePanel() { document.getElementById('tp-panel').hidden = true; state.selected = null; document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected')); } // ── Path to the internet ──────────────────────────────────────────────── function upstream(id) { const links = state.layout.links; const nodes = new Set([id]); const hot = new Set(); const queue = [id]; while (queue.length) { const cur = queue.shift(); links.forEach(l => { if (l.b.node === cur && !l.ghost) { hot.add(l.id); if (!nodes.has(l.a.node)) { nodes.add(l.a.node); queue.push(l.a.node); } } }); } return { nodes, hot }; } // Power feeds, drawn while a power unit or a device it feeds is under the pointer: dotted lines // from the outlet's row on the power card to the device function drawFeeds(id) { const lay = state.layout; const g = document.getElementById('tp-feeds'); if (!g || !lay) return []; const pairs = []; ((state.data && state.data.power) || []).forEach(src => { const sp = lay.pos[src.node]; if (!sp) return; src.outlets.forEach(o => { if (!o.target || !lay.pos[o.target] || (id !== src.node && id !== o.target)) return; const rowY = lay.drawings[src.node].rows[o.index]; if (rowY == null) return; pairs.push({ o, from: { x: sp.x, y: sp.y + rowY }, to: lay.pos[o.target], toH: lay.drawings[o.target].h, target: o.target }); }); }); g.innerHTML = pairs.map(({ o, from, to, toH, target }) => { const B = { x: to.x + to.w, y: to.y + toH / 2 }; if (to.rail) { B.x = to.x; } const c = Math.max(160, Math.abs(from.x - B.x) * 0.4); const d = `M${from.x} ${from.y} C${from.x - c} ${from.y} ${B.x + c} ${B.y} ${B.x} ${B.y}`; const mx = (from.x + B.x) / 2, my = (from.y + B.y) / 2; return ` ⚡ ${esc(target)} ${o.on ? esc(wattText(o.power_w)) : 'off'}`; }).join(''); // Everything at either end of a feed stays lit while the path is traced return new Set(pairs.flatMap(p => [p.target, ...((state.data.power || []).filter(src => src.outlets.includes(p.o)).map(src => src.node))])); } function highlight(id) { const canvas = document.getElementById('tp-canvas'); document.querySelectorAll('.tp-hot').forEach(el => el.classList.remove('tp-hot')); const feeds = document.getElementById('tp-feeds'); if (feeds) feeds.innerHTML = ''; if (!id || !state.layout) { canvas.classList.remove('tp-tracing'); return; } const fed = drawFeeds(id); const { nodes, hot } = upstream(id); fed.forEach(n => nodes.add(n)); nodes.forEach(n => document.querySelector(`.tp-node[data-node="${CSS.escape(n)}"]`)?.classList.add('tp-hot')); hot.forEach(l => document.querySelectorAll(`.tp-link[data-link="${CSS.escape(l)}"]`).forEach(el => el.classList.add('tp-hot'))); canvas.classList.add('tp-tracing'); } // ── Pan and zoom ──────────────────────────────────────────────────────── const K_MIN = 0.03; const K_MAX = 3; // On a narrow screen the diagram is much wider than tall: size the canvas to it instead of // leaving most of a tall canvas empty function sizeCanvas() { const canvas = document.getElementById('tp-canvas'); if (!state.layout || canvas.clientWidth > 700) { canvas.style.height = ''; return; } const { x0, y0, x1, y1 } = state.layout.bounds; const want = canvas.clientWidth * (y1 - y0) / (x1 - x0); canvas.style.height = `${Math.round(Math.max(440, Math.min(want + 24, window.innerHeight * 0.75)))}px`; } function fit() { if (!state.layout) return; const canvas = document.getElementById('tp-canvas'); const { x0, y0, x1, y1 } = state.layout.bounds; const cw = canvas.clientWidth, ch = canvas.clientHeight; const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(cw / (x1 - x0), ch / (y1 - y0)))); state.view = { k, tx: (cw - (x1 - x0) * k) / 2 - x0 * k, ty: (ch - (y1 - y0) * k) / 2 - y0 * k }; applyView(); } function zoomAt(factor, px, py) { const v = state.view; const k = Math.max(K_MIN, Math.min(K_MAX, v.k * factor)); const wx = (px - v.tx) / v.k, wy = (py - v.ty) / v.k; state.view = { k, tx: px - wx * k, ty: py - wy * k }; applyView(); } function zoomCentre(factor) { const canvas = document.getElementById('tp-canvas'); zoomAt(factor, canvas.clientWidth / 2, canvas.clientHeight / 2); } function initPanZoom() { const canvas = document.getElementById('tp-canvas'); const svg = document.getElementById('tp-svg'); const pointers = new Map(); let drag = null; let pinch = null; let suppressClick = false; const local = e => { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; svg.addEventListener('pointerdown', e => { if (e.button !== 0 && e.pointerType === 'mouse') return; pointers.set(e.pointerId, local(e)); if (pointers.size === 1) { drag = { start: local(e), view: Object.assign({}, state.view), id: e.pointerId, moved: false }; } else if (pointers.size === 2) { const [p, q] = [...pointers.values()]; pinch = { dist: Math.hypot(p.x - q.x, p.y - q.y), k: state.view.k }; drag = null; } }); svg.addEventListener('pointermove', e => { if (!pointers.has(e.pointerId)) return; pointers.set(e.pointerId, local(e)); if (pinch && pointers.size === 2) { const [p, q] = [...pointers.values()]; const dist = Math.hypot(p.x - q.x, p.y - q.y); zoomAt((pinch.k * dist / pinch.dist) / state.view.k, (p.x + q.x) / 2, (p.y + q.y) / 2); suppressClick = true; return; } if (!drag || drag.id !== e.pointerId) return; const pt = local(e); const dx = pt.x - drag.start.x, dy = pt.y - drag.start.y; if (!drag.moved && Math.hypot(dx, dy) < 5) return; if (!drag.moved) { drag.moved = true; svg.setPointerCapture(e.pointerId); canvas.classList.add('tp-dragging'); } state.view = { k: drag.view.k, tx: drag.view.tx + dx, ty: drag.view.ty + dy }; applyView(); }); const end = e => { pointers.delete(e.pointerId); if (drag && drag.id === e.pointerId && drag.moved) suppressClick = true; if (pointers.size < 2) pinch = null; if (!pointers.size) drag = null; canvas.classList.remove('tp-dragging'); }; svg.addEventListener('pointerup', end); svg.addEventListener('pointercancel', end); // A drag that ends over a device is not a click on it svg.addEventListener('click', e => { if (suppressClick) { e.preventDefault(); e.stopPropagation(); suppressClick = false; } }, true); svg.addEventListener('wheel', e => { e.preventDefault(); const pt = local(e); zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), pt.x, pt.y); }, { passive: false }); canvas.addEventListener('keydown', e => { if (e.target.closest('input, select, textarea, .tp-panel')) return; if (e.key === '+' || e.key === '=') { zoomCentre(1.25); e.preventDefault(); } else if (e.key === '-' || e.key === '_') { zoomCentre(0.8); e.preventDefault(); } else if (e.key === '0') { fit(); e.preventDefault(); } else if (e.key === 'Escape') hidePanel(); }); } // ── Events ────────────────────────────────────────────────────────────── function initEvents() { const svg = document.getElementById('tp-svg'); svg.addEventListener('click', e => { const outletRow = e.target.closest('[data-outlet]'); if (outletRow && TOPO_CAN_EDIT) { e.preventDefault(); openOutletEditor(outletRow, e); return; } if (!e.target.closest('#tp-pop')) closeOutletEditor(); const port = e.target.closest('[data-action="select-port"]'); if (port) { e.preventDefault(); window.location.href = `${deviceHref(port.dataset.switch)}#port-${encodeURIComponent(port.dataset.portIdx)}`; return; } const group = e.target.closest('[data-expand]'); const link = e.target.closest('.tp-link'); if (group || (link && link.dataset.link.startsWith('bundle:'))) { state.expanded.add(group ? group.dataset.expand : link.dataset.link.slice('bundle:'.length)); render(); return; } if (link) { const found = state.data.links.find(l => l.id === link.dataset.link); if (found) showPanel(found); return; } if (!e.target.closest('.tp-node')) hidePanel(); }); svg.addEventListener('keydown', e => { if (e.key !== 'Enter' && e.key !== ' ') return; const outletRow = e.target.closest('[data-outlet]'); if (outletRow && TOPO_CAN_EDIT) { e.preventDefault(); openOutletEditor(outletRow, null); return; } const group = e.target.closest('[data-expand]'); if (group) { e.preventDefault(); state.expanded.add(group.dataset.expand); render(); return; } const link = e.target.closest('.tp-link'); if (link) { e.preventDefault(); const found = state.data.links.find(l => l.id === link.dataset.link); if (found) showPanel(found); } }); const trace = e => { const node = e.target.closest('.tp-node'); highlight(node ? node.dataset.node : null); }; svg.addEventListener('mouseover', trace); svg.addEventListener('focusin', trace); svg.addEventListener('mouseleave', () => highlight(null)); svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); }); document.getElementById('tp-pop').addEventListener('click', e => { const b = e.target.closest('[data-pop]'); if (!b) return; if (b.dataset.pop === 'save') saveOutlet(); else closeOutletEditor(); }); document.getElementById('tp-pop').addEventListener('keydown', e => { if (e.key === 'Escape') closeOutletEditor(); }); document.getElementById('tp-summary').addEventListener('click', e => { const chip = e.target.closest('.tp-problem-chip'); if (!chip) return; if (chip.dataset.focusNode) focusOn('node', chip.dataset.focusNode); else focusOn('link', chip.dataset.focusLink); }); document.getElementById('tp-panel').addEventListener('click', e => { if (e.target.closest('[data-action="close-panel"]')) hidePanel(); }); document.querySelectorAll('[data-filter]').forEach(el => { el.addEventListener('change', () => { state.filters[el.dataset.filter] = el.tagName === 'SELECT' ? el.value : el.checked; saveFilters(); if (el.dataset.filter === 'stubs') state.expanded.clear(); if (['stubs', 'power', 'mgmt'].includes(el.dataset.filter)) render(); else applyFilterClasses(); }); }); document.querySelectorAll('[data-zoom]').forEach(btn => { btn.addEventListener('click', () => { if (btn.dataset.zoom === 'fit') fit(); else zoomCentre(btn.dataset.zoom === 'in' ? 1.25 : 0.8); }); }); let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(applyView, 150); }); } // ── Outlet editor (admins) ────────────────────────────────────────────── function closeOutletEditor() { const pop = document.getElementById('tp-pop'); if (pop) pop.hidden = true; } function openOutletEditor(row, evt) { const pop = document.getElementById('tp-pop'); const source = row.dataset.outletSource; const index = Number(row.dataset.outlet); const src = powerOf(source); const o = src && src.outlets.find(x => x.index === index); if (!o) return; const names = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind) && n.name !== source) .map(n => n.name).sort((a, b) => a.localeCompare(b)); const current = o.match === 'saved' ? o.target : (o.match === 'cleared' ? '__none' : '__auto'); const opt = (v, t) => ``; pop.innerHTML = `
${esc(source)} outlet ${index}
${esc(o.name || 'no name in UniFi')} · ${o.on ? esc(wattText(o.power_w) || 'on') : 'off'}

Saved in Gandalf only; the outlet keeps its name in UniFi.

`; const canvas = document.getElementById('tp-canvas').getBoundingClientRect(); const r = evt ? { x: evt.clientX, y: evt.clientY } : (() => { const b = row.getBoundingClientRect(); return { x: b.left, y: b.bottom }; })(); pop.hidden = false; const left = Math.min(Math.max(8, r.x - canvas.left - pop.offsetWidth - 12), canvas.width - pop.offsetWidth - 8); const top = Math.min(Math.max(8, r.y - canvas.top - 20), canvas.height - pop.offsetHeight - 8); pop.style.left = `${left}px`; pop.style.top = `${top}px`; pop.dataset.source = source; pop.dataset.outlet = String(index); document.getElementById('tp-pop-select').focus(); } async function saveOutlet() { const pop = document.getElementById('tp-pop'); const v = document.getElementById('tp-pop-select').value; const target = v === '__auto' ? null : (v === '__none' ? '' : v); try { await lt.api.put(`/api/topology/power/${encodeURIComponent(pop.dataset.source)}/${encodeURIComponent(pop.dataset.outlet)}`, { target }); closeOutletEditor(); lt.toast.success('Outlet saved'); refresh(); } catch (e) { lt.toast.error(`Could not save: ${e.message || e}`); } } // ── Problem summary ───────────────────────────────────────────────────── function renderSummary() { const box = document.getElementById('tp-summary'); const problems = problemsOf(state.data); const devices = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind)).length; const cables = state.data.links.length; if (!problems.length) { box.className = 'tp-summary tp-summary-ok'; box.innerHTML = `✓ All clear${devices} devices and ${cables} cables, nothing down or mislabelled`; return; } box.className = `tp-summary ${problems.some(p => p.severity === 'bad') ? 'tp-summary-bad' : 'tp-summary-warn'}`; box.innerHTML = `⚠ ${problems.length} to look at ${problems.map(p => ``).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 => `
${esc(w)}
`).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); });