Files

243 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
let lastNodes = {};
let lastUpdated = 0;
let lastStatus = {};
function setNavActive(which){
const ids = ["navList","navClients","navMonitor","navTgmon","navLastheard","navCTCSS","navMap","navChangelog"];
for(const id of ids){
const el = document.getElementById(id);
if(!el) continue;
el.classList.toggle("active", id === which);
}
}
function setActive(panel){
const listP = document.getElementById('listPanel');
const cliP = document.getElementById('clientsPanel');
const monP = document.getElementById('monitorPanel');
const trkP = document.getElementById('trunksPanel');
const topP = document.getElementById('topologyPanel');
const lhP = document.getElementById('lastheardPanel');
const ctcP = document.getElementById('ctcssPanel');
const chgP = document.getElementById('changelogPanel');
const tgmP = document.getElementById('tgmonPanel');
if(listP) listP.style.display = (panel === 'list') ? 'block' : 'none';
if(cliP) cliP.style.display = (panel === 'clients') ? 'block' : 'none';
if(monP) monP.style.display = (panel === 'monitor') ? 'block' : 'none';
if(trkP) trkP.style.display = (panel === 'trunks') ? 'block' : 'none';
if(topP) topP.style.display = (panel === 'topology') ? 'block' : 'none';
if(lhP) lhP.style.display = (panel === 'lastheard') ? 'block' : 'none';
if(ctcP) ctcP.style.display = (panel === 'ctcss') ? 'block' : 'none';
if(chgP) chgP.style.display = (panel === 'changelog') ? 'block' : 'none';
if(tgmP) tgmP.style.display = (panel === 'tgmon') ? 'block' : 'none';
}
function render(status){
const nodes = status.nodes || {};
lastNodes = nodes;
lastStatus = status;
window.__svxportal_lastStatus = status;
window.__svxportal_lastNodes = nodes;
updateTalkerPopup(nodes, status.talker_meta);
// Header date/time/age
const now = new Date();
document.getElementById('dateTxt').textContent =
now.toLocaleDateString('en-GB', {weekday:'short', month:'short', day:'numeric'});
document.getElementById('timeTxt').textContent =
fmt2(now.getHours()) + ":" + fmt2(now.getMinutes()) + ":" + fmt2(now.getSeconds());
const age = lastUpdated ? Math.max(0, Math.floor((Date.now()-lastUpdated)/1000)) : 0;
document.getElementById('ageTxt').textContent = lastUpdated ? (age + "s") : "-";
// Render visible panels
if (document.getElementById('listPanel')?.style.display !== 'none') renderList(nodes);
if (document.getElementById('clientsPanel')?.style.display !== 'none') renderClients(nodes);
if (document.getElementById('monitorPanel')?.style.display !== 'none') renderMonitor(nodes, status);
if (document.getElementById('trunksPanel')?.style.display !== 'none') window.renderTrunks?.(lastStatus);
if (document.getElementById('topologyPanel')?.style.display !== 'none') window.renderTopology?.(lastStatus);
if (document.getElementById('lastheardPanel')?.style.display !== 'none') window.refreshLastheard?.();
if (document.getElementById('ctcssPanel')?.style.display !== 'none') window.renderCTCSS?.(lastStatus);
if (document.getElementById('tgmonPanel')?.style.display !== 'none') window.renderTgMon?.();
}
async function refresh(){
const j = await apiGet('/svxportal/api/status'); // lib/api.js
if(!j) return;
lastUpdated = Date.now();
render(j);
applyFederationExtra(j);
}
// On the AlabamaLink box fold the AlabamaLink line INTO the existing
// federation marquee so both scroll together in the one bar — same style, no
// second banner. Every other box keeps just the federation line. Gated on the
// local reflector identity from the status (KT4ROY / alabama.reflector-network.com),
// so it's a no-op everywhere else.
let _fedExtraDone = false;
function applyFederationExtra(status){
if(_fedExtraDone) return;
const track = document.querySelector('#federationBanner .fed-track');
if(!track) return;
const refs = (status && status.network && Array.isArray(status.network.reflectors)) ? status.network.reflectors : [];
const isAlabama = refs.some(r => r && r.self && (
String(r.name || '').toUpperCase() === 'KT4ROY' ||
/alabama\.reflector-network\.com/i.test(String(r.hostname || '') + ' ' + String(r.name || ''))
));
if(!isAlabama) return;
_fedExtraDone = true;
const fed = 'Part of the reflector-network.com federation — a growing mesh of independent networks.';
const ala = 'Powered by Executor — a multi-mode digital voice server, part of the AlabamaLink network.';
const sep = '  •  ';
const unit = fed + sep + ala;
const inner = document.createElement('span');
inner.textContent = unit + sep + unit; // doubled so the loop stays seamless
track.replaceChildren(inner);
}
/* ===== Monitor submenu ===== */
function closeMonitorSubMenu(){
const m = document.getElementById('monitorSubMenu');
if(m) m.style.display = 'none';
}
function toggleMonitorSubMenu(){
const m = document.getElementById('monitorSubMenu');
if(!m) return;
m.style.display = (m.style.display === 'none' || !m.style.display) ? 'block' : 'none';
}
document.addEventListener('click', (e) => {
const item = document.getElementById('monitorMenuItem');
if(item && !item.contains(e.target)) closeMonitorSubMenu();
});
/* ===== Wire UI ===== */
document.getElementById('navList')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navList');
setActive('list');
render({nodes:lastNodes});
});
document.getElementById('navClients')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navClients');
setActive('clients');
renderClients(lastNodes);
});
document.getElementById('navMonitor')?.addEventListener('click', (e) => {
e.preventDefault();
toggleMonitorSubMenu();
});
document.getElementById('navMonitorNodes')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navMonitor');
setActive('monitor');
renderMonitor(lastNodes, lastStatus);
});
document.getElementById('navMonitorTrunks')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navMonitor');
setActive('trunks');
window.renderTrunks?.(lastStatus);
});
document.getElementById('navMonitorTopology')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navMonitor');
setActive('topology');
window.renderTopology?.(lastStatus);
});
document.getElementById('navLastheard')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navLastheard');
setActive('lastheard');
window.refreshLastheard?.();
});
document.getElementById('navCTCSS')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navCTCSS');
setActive('ctcss');
window.renderCTCSS?.(lastStatus);
});
document.getElementById('navChangelog')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navChangelog');
setActive('changelog');
chLoad();
});
document.getElementById('navTgmon')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navTgmon');
setActive('tgmon');
window.renderTgMon?.();
});
/* ===== Remember the last view across reloads (Ctrl+Shift+R) ===== */
const RESTORE_NAVS = ['navList','navClients','navMonitorNodes','navMonitorTrunks','navMonitorTopology','navTgmon','navLastheard','navCTCSS','navChangelog'];
for (const id of RESTORE_NAVS) {
document.getElementById(id)?.addEventListener('click', () => { try { localStorage.setItem('svxLastNav', id); } catch (_) {} });
}
(function restoreLastNav(){
let id = null; try { id = localStorage.getItem('svxLastNav'); } catch (_) {}
if (id && RESTORE_NAVS.includes(id)) { const el = document.getElementById(id); if (el) el.click(); }
})();
// Changelog: lib/changelog.js, fed by changelog.json.
const _chViewer = changelogViewer({ body: 'chBody', pager: 'chPager', fetchAt: 'chFetchAt' });
async function chLoad() { return _chViewer.load(); }
document.getElementById('monitorMode')?.addEventListener('change', () => {
renderMonitor(lastNodes, lastStatus);
});
/* ===== Start + SSE ===== */
refresh();
setInterval(() => { // keep Age ticking even without updates
const ageEl = document.getElementById('ageTxt');
if(lastUpdated) ageEl.textContent = Math.floor((Date.now()-lastUpdated)/1000) + "s";
}, 1000);
const sseTxt = document.getElementById('sseTxt');
function setSSE(state){
if(!sseTxt) return;
sseTxt.textContent = "SSE: " + state;
}
const es = new EventSource('/svxportal/api/stream');
es.addEventListener('update', () => refresh());
es.addEventListener('ping', () => {});
es.onopen = () => setSSE('ok');
es.onerror = () => setSSE('down');
setSSE('connecting');