Files
reflector-dashboard/topology_view.js
T

326 lines
17 KiB
JavaScript

// Reflector Dashboard — web front-end for SvxReflector
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
'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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 += `<path d="${curve(e.a,e.b)}" fill="none" stroke="${stroke}" stroke-width="1.6" opacity="${e.ok?0.8:0.5}"`
+ (e.ok?'':' stroke-dasharray="5 4"') + ` marker-end="${e.ok?'url(#tArrowOn)':'url(#tArrowOff)'}"/>`;
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 += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" fill="#7dd3fc" font-size="10" style="pointer-events:none">${escG(displayName(e.name))}</text>`;
}
}
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 `<g class="topo-node" data-id="${escG(node.id)}" transform="translate(${node.x.toFixed(1)},${node.y.toFixed(1)})" style="cursor:pointer">`
+ `<circle r="${rad}" fill="${fill}" stroke="${ring}" stroke-width="${node.isLocal ? 3 : 2}"/>`
+ `<text text-anchor="middle" y="-1" fill="#e2e8f0" font-size="13" font-weight="700" style="pointer-events:none">${escG(displayName(node.label || r.name || '?'))}</text>`
+ `<text text-anchor="middle" y="14" fill="#94a3b8" font-size="10" style="pointer-events:none">${escG(sub)}</text>`
+ `</g>`;
};
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 += `<line x1="${owner.x.toFixed(1)}" y1="${owner.y.toFixed(1)}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${col}" stroke-width="1.3" stroke-dasharray="4 3" opacity="0.75"/>`;
leafSvg += `<g transform="translate(${x.toFixed(1)},${y.toFixed(1)})"><circle r="7" fill="${isSat?'#221a33':'#0d1e35'}" stroke="${col}" stroke-width="1.5"/>`
+ `<text x="11" y="3" fill="#cbd5e1" font-size="10" style="pointer-events:none">${label}</text></g>`;
});
}
const onlineCount = svr.filter(n => n.online).length;
const legend = `<div style="display:flex;gap:14px;flex-wrap:wrap;align-items:center;font-size:11px;color:#94a3b8">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0a2540;border:2px solid #7dd3fc;vertical-align:-1px"></span> you (${escG(localTrunkName)}) — centre</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0d1e35;border:2px solid #22c55e;vertical-align:-1px"></span> online</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2a220d;border:2px solid #f59e0b;vertical-align:-1px"></span> online (firewalled)</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2a0f12;border:2px solid #ef4444;vertical-align:-1px"></span> offline</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#221a33;border:2px solid #a78bfa;vertical-align:-1px"></span> satellite</span>
<span style="color:#64748b">click a reflector for details</span>
<button id="topoReset" type="button" style="font-size:11px;padding:2px 9px;border:1px solid #2d5a9e;border-radius:4px;background:#0a1628;color:#7dd3fc;cursor:pointer">&#8635; Reset view</button>
<span style="margin-left:auto">Reflectors: <b style="color:#7dd3fc">${svr.length}</b> (${onlineCount} online) · Links: <b style="color:#7dd3fc">${edges.length}</b></span>
</div>`;
holder.innerHTML = `${legend}
<div style="position:relative;border:1px solid #1e3a5f;border-radius:8px;overflow:hidden;background:#060d1a;margin-top:8px">
<svg id="topoSvg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:72vh;touch-action:none;user-select:none">
<defs>
<marker id="tArrowOn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#22c55e"/></marker>
<marker id="tArrowOff" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#ef4444"/></marker>
</defs>
<g id="topoRoot" transform="translate(${TOPO.tx},${TOPO.ty}) scale(${TOPO.scale})">
<g>${edgeSvg}</g><g>${leafSvg}</g><g>${nodeSvg}</g>
</g>
</svg>
<div id="topoPopup" style="display:none;position:absolute;max-width:320px;max-height:60%;overflow:auto;background:#0a1628;border:1px solid #2d5a9e;border-radius:8px;padding:10px 12px;font-size:12px;color:#e2e8f0;box-shadow:0 4px 16px rgba(0,0,0,.6);z-index:5"></div>
</div>`;
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 = `<div style="font-weight:700;margin-bottom:4px">${escG(displayName(r.name || '?'))} <span style="color:${stColor}">(${stText})</span></div>`;
const nodes = Array.isArray(r.nodes) ? r.nodes : [];
h += `<div style="font-size:11px;margin-bottom:2px">Nodes: <b>${nodes.length}</b></div>`;
if(nodes.length){
const rows = nodes.slice().sort((a,b)=> String(a&&a.callsign).localeCompare(String(b&&b.callsign)))
.map(n => `<div class="mono" style="font-size:11px">${escG(n.callsign)}${(n.tg? ' · TG'+escG(n.tg):'')}${(n.location? ' · '+escG(n.location):'')}</div>`).join('');
h += `<div style="margin-top:2px">${rows}</div>`;
}
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;