243 lines
9.1 KiB
JavaScript
243 lines
9.1 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';
|
||
|
||
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');
|
||
|