// 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 = `
${m ? 'No matching nodes' : 'No connected nodes'}
`; 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) ? ` (${escClient(n.sysop)})` : ''; 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 = '—'; } else { actions = `` + ``; } } return `` + `${escClient(cs)}${sysop}` + `${escClient(cls)}` + `${curTg > 0 ? curTg : ''}` + `${escClient(loc)}` + `${freq}` + `${escClient(sw)}` + `${tgs.length ? tgs.join(', ') : ''}` + actions + ``; }).join(''); body.innerHTML = '
' + '' + (isAdmin ? '' : '') + '' + '' + rows + '
CallsignClassTGLocationRX / TXSWMonitored TGsActions
'; } // 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])); }