// 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'; // Monitor → Trunks: ONLY the trunks THIS reflector is configured to trunk against, // read from the local svxreflector.conf (nodelist /api/local-trunks). The whole // network is on Monitor → Topology instead. No host/IP is ever shown. Footer // pager (first/prev/next/last, rows/page fit the screen) + regex search. (function () { let data = { trunks: [] }; let started = false, timer = null, _page = 1; const EXPANDED = new Set(); // trunk names whose TG list is expanded (persists across polls) function trunkDot(ok){ return ok ? '●' : '●'; } // Rows/page fit the space under the table so it never scrolls (same as the // node / TG pages). // Search matcher: the term is a case-insensitive regex (^se|us$, sm[0-9]). // Invalid regex falls back to a plain substring match. Empty → null. function trunkMatcher(term){ term = String(term == null ? '' : term).trim(); if(!term) return null; try { const re = new RegExp(term, 'i'); return (v) => re.test(String(v == null ? '' : v)); } catch(_) { const lc = term.toLowerCase(); return (v) => String(v == null ? '' : v).toLowerCase().indexOf(lc) !== -1; } } async function fetchData(){ try { const r = await fetch('/svxportal/api/local-trunks', { cache: 'no-store' }); if(r.ok){ data = await r.json(); render(); } } catch(_){} } function render(){ const body = document.getElementById('trunksBody'); if(!body) return; let trunks = Array.isArray(data.trunks) ? data.trunks : []; const m = trunkMatcher((document.getElementById('trunksSearch') || {}).value); if(m) trunks = trunks.filter(t => t && m(t.name)); trunks = trunks.slice().sort((a,b)=> String(a && a.name).localeCompare(String(b && b.name))); if(!trunks.length){ body.innerHTML = '
' + (m ? 'No matching trunks.' : 'No trunks configured in svxreflector.conf.') + '
'; return; } const ps = pageSizeFor('trunksBody'); const pages = Math.max(1, Math.ceil(trunks.length / ps)); if(_page > pages) _page = pages; if(_page < 1) _page = 1; const slice = trunks.slice((_page - 1) * ps, (_page - 1) * ps + ps); let html = `
`; for(const t of slice){ if(!t) continue; const ok = !!t.connected; // Satellite trunks carry N talkgroups under the SAME trunk name. The row is a // collapsible header: a chevron + "(N TG)"; click to expand/collapse the TGs. const tgs = Array.isArray(t.tgs) ? t.tgs : []; const hasTgs = tgs.length > 0; const open = hasTgs && EXPANDED.has(t.name); const chev = hasTgs ? `${open ? '▾' : '▸'} ` : ''; html += ` `; if(open){ for(const g of tgs){ html += ``; } } } html += `
NameStatusNodes
${chev}${escHtml(t.name)}${hasTgs ? ` (${tgs.length} TG)` : ''} ${trunkDot(ok)} ${ok ? 'online' : 'offline'} ${Number(t.nodes || 0)}
` + escHtml(g.name || ('TG ' + (g.tg || ''))) + (g.tg ? ' · TG ' + escHtml(g.tg) : '') + `
` + pagerHtml({ page: _page, pages, total: trunks.length, noun: 'trunks', cls: 'trk-pg' }); body.innerHTML = html; body.querySelectorAll('.trk-pg[data-pg]').forEach(b => b.addEventListener('click', () => { const p = parseInt(b.dataset.pg, 10); if(p >= 1 && p <= pages && p !== _page){ _page = p; render(); } })); // Expand/collapse a satellite trunk's talkgroups on click. body.querySelectorAll('.trk-row[data-trunk]').forEach(r => r.addEventListener('click', () => { const n = r.dataset.trunk; if(EXPANDED.has(n)) EXPANDED.delete(n); else EXPANDED.add(n); render(); })); } // Called by app.js (nav click + every status poll while the panel is visible). window.renderTrunks = function(){ if(!started){ started = true; fetchData(); if(!timer) timer = setInterval(() => { if(document.getElementById('trunksPanel')?.style.display !== 'none') fetchData(); }, 5000); } render(); }; // Live regex search (reset to page 1) + re-fit on resize. document.addEventListener('input', function(e){ if(e.target && e.target.id === 'trunksSearch'){ _page = 1; render(); } }); window.addEventListener('resize', function(){ if(document.getElementById('trunksPanel')?.style.display !== 'none') render(); }); })();