// 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';
// 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 = `
';
}
// 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 => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
}[c]));
}