// SvxPortal — 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');