Public web-only release: SvxPortal front-end + auth backend, install templates and INSTALL.md
Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr), deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles live in install/ with CHANGEME placeholders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
'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 (aldmr) 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');
|
||||
|
||||
Reference in New Issue
Block a user