326 lines
17 KiB
JavaScript
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,'&').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 += `<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">↻ 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;
|