'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 += `