'use strict'; // Monitor → Topology. Reads the complete structure nodelist collected // (status.network) and draws it: the local reflector (self) in the centre, every // other reflector in a ring (online + offline), trunks drawn between the servers // they actually connect, plus the master→uplink link. Guarded throughout. function escG(s){ return String(s == null ? '' : s) .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function normHost(h){ h = String(h == null ? '' : h).trim().toLowerCase(); if(!h) return ''; return h.replace(/^[a-z]+:\/\//,'').replace(/\/.*$/,'').replace(/:\d+$/,'').replace(/^\[|\]$/g,''); } // A label may arrive as a raw federation-seed status URL (http[s]://host:port/ // status). NEVER show host:port/path in the UI — it is an attack surface. Strip // any URL-shaped label down to the hostname only; leave normal names untouched. function displayName(s){ s = String(s == null ? '' : s); return /^[a-z]+:\/\//i.test(s.trim()) ? normHost(s) : s; } const TOPO = { tx:0, ty:0, scale:1, drag:null }; const TOPO_W = 1500, TOPO_H = 1040; function renderTopology(status){ const holder = document.getElementById('topologyBody'); if(!holder) return; status = status || window.__svxportal_lastStatus || {}; const net = status.network || {}; const reflectors = Array.isArray(net.reflectors) ? net.reflectors : []; const localRef = reflectors.find(r => r && r.type === 'local') || { name:'Local', nodes:[], trunks:[], type:'local', online:true }; const others = reflectors.filter(r => r && r.type === 'reflector' && !r.self); const graphRefs = [localRef].concat(others); // Always anchor the map on THIS reflector — the local server is the centre, // every other reflector sits in the ring around it. const centerRef = localRef; const ringRefs = graphRefs.filter(r => r !== centerRef); // Identities of known nodes — a trunk to one becomes an edge; else a leaf. const idSet = new Set(); const addIds = (r) => { [r.name, r.hostname, r.ip].forEach(k => { k = normHost(k); if(k) idSet.add(k); }); (Array.isArray(r.aliases) ? r.aliases : []).forEach(a => { const k = normHost(a); if(k) idSet.add(k); }); }; graphRefs.forEach(addIds); reflectors.filter(r => r && r.self).forEach(addIds); // self IPs resolve to the local node const isLeafTrunk = (t) => t && !idSet.has(normHost(t.host)); const leafCount = ringRefs.map(r => (Array.isArray(r.trunks) ? r.trunks : []).filter(isLeafTrunk).length); // Layout DERIVED from the data: each ring node gets an arc for its (long) label // + its leaves; the radius/canvas follow so nothing overlaps. const maxLeaf = Math.max.apply(null, leafCount.concat([0])); const PER_LEAF = 80; const arcNeed = ringRefs.map((r, i) => Math.max(150, String(r.name || '').length * 8 + 40) + leafCount[i] * PER_LEAF); const totalArc = arcNeed.reduce((a, b) => a + b, 0) || 1; const R = Math.round(Math.max(360, totalArc / (2 * Math.PI)) / 50) * 50; const leafReach = Math.round((130 + maxLeaf * 16) / 25) * 25; const W = 2 * (R + leafReach + 100), H = W, cx = W/2, cy = H/2; if(TOPO.w !== W || TOPO.h !== H){ TOPO.tx = 0; TOPO.ty = 0; TOPO.scale = 1; } TOPO.w = W; TOPO.h = H; const center = { id:'__center', ref:centerRef, central:true, isLocal:(centerRef === localRef), online:centerRef.online !== false, x:cx, y:cy }; let acc = 0; const svr = ringRefs.map((r, i) => { const a = -Math.PI/2 + (2*Math.PI) * ((acc + arcNeed[i]/2) / totalArc); acc += arcNeed[i]; return { id:'s'+i, ref:r, isLocal:(r === localRef), online:r.online !== false, x:cx + Math.cos(a)*R, y:cy + Math.sin(a)*R }; }); const allNodes = [center].concat(svr); const localNode = allNodes.find(n => n.isLocal) || center; // Label the local (home) node with the SERVER's own name — the self hostname // the crawl found (e.g. alabama.reflector-network.com), else the reflector // name. NOT a trunk name (that pointed at a remote hub and read as "the hub is // in the centre"). const selfHost = (reflectors.find(r => r && r.self && r.type !== 'local' && /[a-z].*\./i.test(String(r.name || ''))) || {}).name; const localTrunkName = selfHost || localRef.name || 'Local'; if(localNode) localNode.label = localTrunkName; // Resolve a trunk host -> a node, by name / hostname / ip / alias. const index = {}; const put = (k, node) => { k = normHost(k); if(k) index[k] = node; }; for(const node of allNodes){ const r = node.ref || {}; put(r.name, node); put(r.hostname, node); put(r.ip, node); (Array.isArray(r.aliases) ? r.aliases : []).forEach(a => put(a, node)); } // Any trunk pointing at a self identity (the local reflector's public IP/hostname) // → the local node, so its uplink connects as an edge instead of a dangling leaf. reflectors.filter(r => r && r.self).forEach(r => { put(r.name, localNode); put(r.hostname, localNode); put(r.ip, localNode); (Array.isArray(r.aliases) ? r.aliases : []).forEach(a => put(a, localNode)); }); const resolve = (h) => index[normHost(h)] || null; // Edges. const edges = [], edgeByKey = {}; const addEdge = (a, b, ok, name) => { if(!a || !b || a === b) return; const k = [a.id, b.id].sort().join('|'); if(edgeByKey[k]){ if(!edgeByKey[k].name && name) edgeByKey[k].name = name; // keep the trunk name if(ok) edgeByKey[k].ok = true; return; } const e = { a, b, ok:!!ok, name: name || '' }; edgeByKey[k] = e; edges.push(e); }; // node-count per trunk name (for remote-leaf labels). const trunkCount = {}; (Array.isArray(net.trunks) ? net.trunks : []).forEach(t => { if(t) trunkCount[t.name] = t.node_count || 0; }); // Every node's trunks -> edge to the resolved peer, or a remote leaf. The local // reflector's dangling uplink trunk isn't drawn as a leaf (the hub's trunk back // to us already provides that named edge). const leaves = [], leafSeen = new Set(); for(const node of allNodes){ const tks = Array.isArray(node.ref && node.ref.trunks) ? node.ref.trunks : []; for(const t of tks){ if(!t) continue; const other = resolve(t.host); if(other){ addEdge(node, other, !!t.connected, t.name); continue; } // The local reflector's dangling uplink trunk isn't drawn as a leaf (the // hub's trunk back already gives that edge) — but its OWN satellite trunks // (chan_svx_sat, e.g. KT4ROY-SAT1) ARE leaves off us and must show. if(node.isLocal && t.kind !== 'satellite') continue; if(leafSeen.has(t.name)) continue; leafSeen.add(t.name); leaves.push({ owner: node, t: t }); } } const curve = (a, b) => { const mx=(a.x+b.x)/2, my=(a.y+b.y)/2, dx=b.x-a.x, dy=b.y-a.y; const nx=-dy, ny=dx, len=Math.hypot(nx,ny)||1; return `M${a.x.toFixed(1)},${a.y.toFixed(1)} Q${(mx+nx/len*26).toFixed(1)},${(my+ny/len*26).toFixed(1)} ${b.x.toFixed(1)},${b.y.toFixed(1)}`; }; let edgeSvg = ''; for(const e of edges){ const stroke = e.ok ? '#22c55e' : '#ef4444'; edgeSvg += ``; if(e.name){ const mx=(e.a.x+e.b.x)/2, my=(e.a.y+e.b.y)/2, dx=e.b.x-e.a.x, dy=e.b.y-e.a.y, len=Math.hypot(dx,dy)||1; const lx=mx + (-dy/len)*15, ly=my + (dx/len)*15; edgeSvg += `${escG(displayName(e.name))}`; } } const drawNode = (node) => { const r = node.ref || {}; const fw = !!r.firewalled; const online = node.online !== false; const rad = node.central ? 38 : (node.isLocal ? 30 : 26); const count = Array.isArray(r.nodes) ? r.nodes.length : 0; let ring = fw ? '#f59e0b' : (online ? '#22c55e' : '#ef4444'); let fill = node.central ? '#12325c' : (fw ? '#2a220d' : (online ? '#0d1e35' : '#2a0f12')); if(node.central) ring = '#93c5fd'; // hub if(node.isLocal){ ring = '#7dd3fc'; fill = '#0a2540'; } // YOUR reflector void online; const sub = node.isLocal ? ('you · ' + count + ' nodes') : (fw ? 'online (fw)' : (online ? (count + ' nodes') : 'offline')); return `` + `` + `${escG(displayName(node.label || r.name || '?'))}` + `${escG(sub)}` + ``; }; let nodeSvg = ''; for(const node of allNodes) nodeSvg += drawNode(node); // Remote leaves: every trunk whose far end isn't a known reflector (incl. big // trunk networks like TRUNK_DE_IT and satellites) is drawn as a leaf off its // owner, labelled name + carried-node count. So all trunks are visible. let leafSvg = ''; const leavesByOwner = new Map(); for(const lf of leaves){ if(!leavesByOwner.has(lf.owner.id)) leavesByOwner.set(lf.owner.id, []); leavesByOwner.get(lf.owner.id).push(lf); } for(const arr of leavesByOwner.values()){ const owner = arr[0].owner; const away = Math.atan2(owner.y - cy, owner.x - cx) || -Math.PI/2; const nLeaf = arr.length; const fan = Math.min(Math.PI*1.4, nLeaf * 0.5); // wide fan when there are many arr.forEach((lf, i) => { const t = lf.t; const isSat = t.kind === 'satellite'; const ang = away + (nLeaf === 1 ? 0 : (-fan/2 + fan * i/(nLeaf-1))); const rr = leafReach + (i % 2) * 36; // stagger radius so adjacent labels don't overlap const x = owner.x + Math.cos(ang)*rr, y = owner.y + Math.sin(ang)*rr; const col = isSat ? '#a78bfa' : (t.connected ? '#22c55e' : '#ef4444'); const cnt = trunkCount[t.name] || 0; const label = escG(displayName(t.name)) + (isSat ? ' (sat)' : (cnt ? ' (' + cnt + ')' : '')); leafSvg += ``; leafSvg += `` + `${label}`; }); } const onlineCount = svr.filter(n => n.online).length; const legend = `
you (${escG(localTrunkName)}) — centre online online (firewalled) offline satellite click a reflector for details Reflectors: ${svr.length} (${onlineCount} online) · Links: ${edges.length}
`; holder.innerHTML = `${legend}
${edgeSvg}${leafSvg}${nodeSvg}
`; wireTopology(allNodes); } function reflectorPopupHtml(r){ r = r || {}; const online = r.online !== false; const fw = !!r.firewalled; const stColor = fw ? '#f59e0b' : (online ? '#22c55e' : '#ef4444'); const stText = fw ? 'online (firewalled)' : (online ? 'online' : 'offline'); // NAME ONLY — never expose host connection details (IP/port/hostname/alias/ // status URL). Exposing them makes the reflector a DDoS/attack target. let h = `
${escG(displayName(r.name || '?'))} (${stText})
`; const nodes = Array.isArray(r.nodes) ? r.nodes : []; h += `
Nodes: ${nodes.length}
`; if(nodes.length){ const rows = nodes.slice().sort((a,b)=> String(a&&a.callsign).localeCompare(String(b&&b.callsign))) .map(n => `
${escG(n.callsign)}${(n.tg? ' · TG'+escG(n.tg):'')}${(n.location? ' · '+escG(n.location):'')}
`).join(''); h += `
${rows}
`; } return h; } function wireTopology(allNodes){ const svg = document.getElementById('topoSvg'); const root = document.getElementById('topoRoot'); const popup = document.getElementById('topoPopup'); if(!svg || !root) return; const apply = () => root.setAttribute('transform', `translate(${TOPO.tx},${TOPO.ty}) scale(${TOPO.scale})`); svg.addEventListener('wheel', (e) => { e.preventDefault(); const f = e.deltaY < 0 ? 1.12 : 1/1.12; const rect = svg.getBoundingClientRect(); const mx = (e.clientX-rect.left)/rect.width*(TOPO.w||1500), my = (e.clientY-rect.top)/rect.height*(TOPO.h||1500); const ns = Math.min(4, Math.max(0.3, TOPO.scale*f)); TOPO.tx = mx - (mx-TOPO.tx)*(ns/TOPO.scale); TOPO.ty = my - (my-TOPO.ty)*(ns/TOPO.scale); TOPO.scale = ns; apply(); }, { passive:false }); svg.addEventListener('pointerdown', (e) => { if(e.target.closest('.topo-node')) return; TOPO.drag = { x:e.clientX, y:e.clientY, tx:TOPO.tx, ty:TOPO.ty }; svg.setPointerCapture(e.pointerId); if(popup) popup.style.display='none'; }); svg.addEventListener('pointermove', (e) => { if(!TOPO.drag) return; const rect = svg.getBoundingClientRect(); TOPO.tx = TOPO.drag.tx + (e.clientX-TOPO.drag.x)*((TOPO.w||1500)/rect.width); TOPO.ty = TOPO.drag.ty + (e.clientY-TOPO.drag.y)*((TOPO.h||1500)/rect.height); apply(); }); svg.addEventListener('pointerup', () => { TOPO.drag = null; }); const rb = document.getElementById('topoReset'); if(rb) rb.addEventListener('click', (e) => { e.stopPropagation(); TOPO.tx = 0; TOPO.ty = 0; TOPO.scale = 1; renderTopology(); }); const byId = {}; for(const n of allNodes) byId[n.id] = n; svg.querySelectorAll('.topo-node').forEach(g => { g.addEventListener('click', (e) => { e.stopPropagation(); const node = byId[g.getAttribute('data-id')]; if(!node) return; showTopoPopup(g, reflectorPopupHtml(node.ref)); }); }); if(popup) popup.addEventListener('click', () => popup.style.display='none'); } function showTopoPopup(g, html){ const popup = document.getElementById('topoPopup'), svg = document.getElementById('topoSvg'); if(!popup || !svg) return; const rect = svg.getBoundingClientRect(), gb = g.getBoundingClientRect(); popup.innerHTML = html; popup.style.display = 'block'; popup.style.left = Math.max(4, Math.min(gb.left-rect.left+10, rect.width-280)) + 'px'; popup.style.top = Math.max(4, gb.top-rect.top+10) + 'px'; } window.renderTopology = renderTopology;