Files
reflector-dashboard/clients_view.js
T
bjorneandClaude Fable 5.1 25b481e2ae 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>
2026-09-26 23:07:05 +02:00

173 lines
6.7 KiB
JavaScript

'use strict';
// Search matcher: glob wildcards * and ? → anchored regex; else substring.
function clientMatcher(term){
term = String(term == null ? '' : term).trim().toLowerCase();
if(!term) return null;
if(term.indexOf('*') !== -1 || term.indexOf('?') !== -1){
const re = new RegExp('^' + term.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
return (v) => re.test(String(v == null ? '' : v));
}
return (v) => String(v == null ? '' : v).toLowerCase().indexOf(term) !== -1;
}
let CLIENTS_NODES = null; // last nodes, so the search box can re-render
function renderClients(nodes){
const body = document.getElementById('clientsBody');
if(!body) return;
CLIENTS_NODES = nodes || {};
const isAdmin = !!window._isAdmin;
// Reflector clients = only nodes connected to THIS local reflector. Trunk-side
// nodes (tagged remoteTrunk) live on other reflectors — not our clients.
const m = clientMatcher((document.getElementById('clientsSearch') || {}).value);
let calls = Object.keys(nodes || {}).filter(cs => {
const n = nodes[cs]; return n && !n.remoteTrunk;
}).sort();
if(m){
calls = calls.filter(cs => m(cs) || m(String((nodes[cs] || {}).tg == null ? '' : (nodes[cs] || {}).tg)));
}
if(calls.length === 0){
body.innerHTML = `<div class="text-secondary">${m ? 'No matching nodes' : 'No connected nodes'}</div>`;
return;
}
const rows = calls.map(cs => {
const n = nodes[cs] || {};
const cls = n.nodeClass || '';
const tgs = Array.isArray(n.monitoredTGs) ? n.monitoredTGs.slice() : [];
tgs.sort((a,b)=>Number(a)-Number(b));
const loc = clientLocation(n);
const fr = clientFreqs(n);
const curTg = Number(n.tg || 0);
const sysop = (n.sysop && n.sysop !== cs) ? ` <span class="text-secondary">(${escClient(n.sysop)})</span>` : '';
const freq = (fr.rx || fr.tx) ? `${fr.rx || '-'} / ${fr.tx || '-'}` : '';
const sw = [n.sw, n.swVer].filter(Boolean).join(' ');
const trAttr = n.isTalker ? ' style="color:#ff5252;font-weight:bold;"' : '';
const remote = n.remoteTrunk ? ` title="via ${escClient(n.remoteTrunk)}"` : '';
let txCtcss = '';
const t2t = n.toneToTalkgroup;
if (curTg > 0 && t2t && typeof t2t === 'object'){
for (const tone in t2t){ if (Number(t2t[tone]) === curTg){ txCtcss = tone; break; } }
}
const rxCtcss = (n.reportCtcss == null || n.reportCtcss === '') ? '' : String(n.reportCtcss);
const info = window.buildRowInfo ? window.buildRowInfo([
['Callsign', cs], ['Location', loc], ['TG', curTg > 0 ? curTg : ''],
['RX freq', fr.rx], ['TX freq', fr.tx],
['RX subtone', rxCtcss], ['TX subtone', txCtcss],
['Class', cls], ['SW', sw], ['Monitored TGs', tgs.length ? tgs.join(', ') : ''],
['Node admin', n.sysop], ['Via trunk', n.remoteTrunk]
], { talking: !!n.isTalker }) : '';
let actions = '';
if (isAdmin) {
if (n.remoteTrunk) {
actions = '<td class="text-secondary nowrap" title="Remote (trunk) node — cannot block">&mdash;</td>';
} else {
actions = `<td class="nowrap">`
+ `<button type="button" class="btn btn-sm btn-warning node-blk" data-blk="${escClient(cs)}" title="Enter 0 minutes to unblock" style="min-width:86px;">Block</button></td>`;
}
}
return `<tr${trAttr}${info ? ` data-info="${info}"` : ''}>`
+ `<td class="mono nowrap"${remote}><b>${escClient(cs)}</b>${sysop}</td>`
+ `<td class="nowrap">${escClient(cls)}</td>`
+ `<td class="mono nowrap">${curTg > 0 ? curTg : ''}</td>`
+ `<td class="wrap">${escClient(loc)}</td>`
+ `<td class="mono nowrap">${freq}</td>`
+ `<td class="nowrap">${escClient(sw)}</td>`
+ `<td class="mono wrap">${tgs.length ? tgs.join(', ') : ''}</td>`
+ actions
+ `</tr>`;
}).join('');
body.innerHTML = '<div class="table-responsive"><table class="table table-sm mb-0 data-table">'
+ '<thead><tr><th>Callsign</th><th>Class</th><th>TG</th><th>Location</th><th>RX / TX</th><th>SW</th><th>Monitored TGs</th>'
+ (isAdmin ? '<th>Actions</th>' : '')
+ '</tr></thead>'
+ '<tbody>' + rows + '</tbody></table></div>';
}
// CSP-safe click delegation, wired ONCE at load on document (inline onclick is blocked by the
// page CSP). Wiring here — not inside renderClients — means it works on the very first click,
// regardless of the poll re-rendering the table.
document.addEventListener('click', function(e) {
var t = e.target;
if (!t || !t.closest) return;
var blk = t.closest('[data-blk]');
if (blk) { toggleBlock(blk.getAttribute('data-blk')); return; }
});
// Live re-render as the callsign/TG filter is typed.
document.addEventListener('input', function(e) {
if (e.target && e.target.id === 'clientsSearch') renderClients(CLIENTS_NODES || {});
});
/* ---- Admin: block / unblock a locally connected node (runtime, via PTY) ---- */
function _blockApi(body){
return fetch('/auth/admin_reflector_api.php', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''},
body:JSON.stringify(body)
}).then(r => r.json()).catch(() => null);
}
function toggleBlock(cs){
var v = prompt('Block ' + cs + ' \u2014 minutes (0 = unblock):', '5');
if (v === null) return;
var mins = parseInt(v, 10);
if (isNaN(mins) || mins < 0){ alert('Enter 0 (unblock) or a positive number of minutes.'); return; }
if (mins === 0){
_blockApi({action:'unblock', callsign:cs}).then(function(j){
if (j && j.ok) alert(cs + ' unblocked');
else alert((j && j.error) || 'Unblock failed');
});
} else {
_blockApi({action:'block', callsign:cs, seconds:mins*60}).then(function(j){
if (j && j.ok) alert('Blocked ' + cs + ' for ' + mins + ' min');
else alert((j && j.error) || 'Block failed');
});
}
}
window.toggleBlock = toggleBlock;
function clientLocation(n){
if (n.nodeLocation) return String(n.nodeLocation);
if (Array.isArray(n.qth)){
for (const q of n.qth){
if (q && q.name) return String(q.name);
}
}
return '';
}
function clientFreqs(n){
let rx = 0, tx = 0;
if (Array.isArray(n.qth)){
for (const q of n.qth){
if (!rx && q && q.rx){
for (const id in q.rx){
const r = q.rx[id];
if (r && Number(r.freq) > 0){ rx = Number(r.freq); break; }
}
}
if (!tx && q && q.tx){
for (const id in q.tx){
const t = q.tx[id];
if (t && Number(t.freq) > 0){ tx = Number(t.freq); break; }
}
}
if (rx && tx) break;
}
}
return { rx: rx ? rx.toFixed(4) : '', tx: tx ? tx.toFixed(4) : '' };
}
function escClient(s){
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}