136 lines
5.6 KiB
JavaScript
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;">●</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();
|
|
});
|
|
})();
|