182 lines
7.1 KiB
JavaScript
182 lines
7.1 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';
|
|
|
|
// 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">—</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 => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|