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:
+126
@@ -0,0 +1,126 @@
|
||||
'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
|
||||
? '<span style="color:#00ff00;">●</span>'
|
||||
: '<span style="color:#ff4040;">●</span>';
|
||||
}
|
||||
|
||||
// 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 = '<div class="text-secondary">' +
|
||||
(m ? 'No matching trunks.' : 'No trunks configured in svxreflector.conf.') + '</div>';
|
||||
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 = `<div class="table-responsive mb-2"><table class="table table-sm mb-0 data-table" style="table-layout:fixed;width:100%">
|
||||
<colgroup><col style="width:56%"><col style="width:26%"><col style="width:18%"></colgroup>
|
||||
<thead><tr><th>Name</th><th>Status</th><th class="nowrap">Nodes</th></tr></thead><tbody>`;
|
||||
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
|
||||
? `<span style="display:inline-block;width:1em;color:#7dd3fc">${open ? '▾' : '▸'}</span> `
|
||||
: '';
|
||||
html += `<tr${hasTgs ? ` class="trk-row" data-trunk="${escHtml(t.name)}" style="cursor:pointer"` : ''}>
|
||||
<td class="mono wrap">${chev}${escHtml(t.name)}${hasTgs ? ` <span class="small text-secondary">(${tgs.length} TG)</span>` : ''}</td>
|
||||
<td class="nowrap">${trunkDot(ok)} ${ok ? 'online' : 'offline'}</td>
|
||||
<td class="mono">${Number(t.nodes || 0)}</td>
|
||||
</tr>`;
|
||||
if(open){
|
||||
for(const g of tgs){
|
||||
html += `<tr class="trk-tg"><td class="small text-secondary" style="padding-left:2em">`
|
||||
+ escHtml(g.name || ('TG ' + (g.tg || ''))) + (g.tg ? ' · TG ' + escHtml(g.tg) : '')
|
||||
+ `</td><td></td><td></td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
html += `</tbody></table></div>` + 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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user