Files

136 lines
5.6 KiB
JavaScript

// Reflector Dashboard — 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
? '<span style="color:#00ff00;">&#9679;</span>'
: '<span style="color:#ff4040;">&#9679;</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 ? '&#9662;' : '&#9656;'}</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();
});
})();