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:
2026-09-26 23:07:05 +02:00
co-authored by Claude Fable 5.1
commit 25b481e2ae
93 changed files with 13357 additions and 0 deletions
+233
View File
@@ -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');