// 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'; function switchProfileTab(tab) { document.querySelectorAll('.profile-tab').forEach(function(b) { var active = b.dataset.ptab === tab; b.classList.toggle('active', active); b.setAttribute('aria-selected', active ? 'true' : 'false'); }); document.querySelectorAll('.profile-tab-pane').forEach(function(p) { p.style.display = p.id === 'ptab-' + tab ? '' : 'none'; }); if (tab === 'nodes' && !nodesLoaded) loadNodes(); if (tab === 'mqtt' && !mqttLoaded) loadMqtt(); if (tab === 'nodesec' && !nodeSecLoaded) { nodeSecLoaded = true; loadNodeAuth(); } } var nodesLoaded = false; var mqttLoaded = false; var nodeSecLoaded = false; var allNodes = {}; async function loadNodes() { var status = document.getElementById('nodesStatus'); var list = document.getElementById('nodesList'); var selWrap = document.getElementById('nodeSelWrap'); var sel = document.getElementById('nodeSelect'); try { var r = await fetch('/auth/my_nodes.php', {credentials:'same-origin'}); var j = await r.json(); if (!j || !j.ok) { status.textContent = j && j.error || 'Error'; return; } nodesLoaded = true; allNodes = j.nodes || {}; var keys = Object.keys(allNodes); if (keys.length === 0) { status.textContent = 'No nodes connected'; list.innerHTML = '
No nodes matching your callsign are currently connected to the reflector.
'; selWrap.style.display = 'none'; return; } status.textContent = keys.length + ' node' + (keys.length > 1 ? 's' : ''); // Populate instance selector sel.innerHTML = ''; if (keys.length > 1) { var optAll = document.createElement('option'); optAll.value = '__all__'; optAll.textContent = 'All nodes (' + keys.length + ')'; sel.appendChild(optAll); } for (var i = 0; i < keys.length; i++) { var opt = document.createElement('option'); opt.value = keys[i]; opt.textContent = keys[i]; sel.appendChild(opt); } selWrap.style.display = (keys.length > 1) ? '' : 'none'; // Show first node or all if (keys.length > 1) { showNodes('__all__'); } else { showNodes(keys[0]); } } catch(e) { status.textContent = 'Failed to load'; } } function showNodes(selected) { var list = document.getElementById('nodesList'); list.innerHTML = ''; var keys = Object.keys(allNodes); for (var i = 0; i < keys.length; i++) { if (selected !== '__all__' && keys[i] !== selected) continue; list.appendChild(renderNode(keys[i], allNodes[keys[i]])); } } function renderNode(callsign, n) { var card = document.createElement('div'); card.className = 'node-card'; var html = '
' + esc(callsign) + ''; if (n.nodeClass) html += ' ' + esc(n.nodeClass) + ''; if (n.isTalker) html += ' TX'; html += '
'; html += '
'; if (n.nodeLocation) html += dt('Location') + dd(n.nodeLocation); if (n.sysop) html += dt('Sysop') + dd(n.sysop); if (n.sw) html += dt('Software') + dd(n.sw + ' ' + (n.swVer || '')); if (n.projVer) html += dt('Build') + dd(n.projVer); if (n.machineArch) html += dt('Arch') + dd(n.machineArch); if (n.protoVer) html += dt('Protocol') + dd('v' + n.protoVer.majorVer + '.' + n.protoVer.minorVer); if (n.tg !== undefined) html += dt('Current TG') + dd(n.tg || 'None'); html += '
'; // Node admins editor appended below (dmr-style table); sysop is the // implicit owner, others editable by owner / node admin / site admin. if (n.monitoredTGs && n.monitoredTGs.length > 0) { html += '
Monitored TGs:
'; for (var i = 0; i < n.monitoredTGs.length; i++) html += '' + n.monitoredTGs[i] + ''; html += '
'; } if (n.toneToTalkgroup && Object.keys(n.toneToTalkgroup).length > 0) { html += '
CTCSS Tone Mapping:'; html += ''; var sorted = Object.entries(n.toneToTalkgroup).sort(function(a,b){ return parseFloat(a[0]) - parseFloat(b[0]); }); for (var i = 0; i < sorted.length; i++) { html += ''; } html += '
' + sorted[i][0] + ' HzTG ' + sorted[i][1] + '
'; } if (n.qth && n.qth.length > 0) { for (var qi = 0; qi < n.qth.length; qi++) { var q = n.qth[qi]; html += '
'; html += 'QTH: ' + esc(q.name || '') + ''; html += '
'; if (q.pos) { if (q.pos.loc) html += dt('Locator') + dd(q.pos.loc); if (q.pos.lat) html += dt('Lat/Long') + dd(q.pos.lat + ', ' + q.pos.long); } if (q.rx) { for (var id in q.rx) { var rx = q.rx[id]; html += dt('RX ' + id) + dd((rx.name||'') + ' ' + (rx.freq ? rx.freq.toFixed(3)+' MHz':'') + (rx.sqlType ? ' ('+rx.sqlType+')':'')); html += dt('Status') + dd((rx.enabled?'Enabled':'Disabled') + (rx.sql_open?', Squelch open':'')); } } if (q.tx) { for (var id in q.tx) { var tx = q.tx[id]; html += dt('TX ' + id) + dd((tx.name||'') + ' ' + (tx.freq ? tx.freq.toFixed(3)+' MHz':'') + (tx.sqlType ? ' ('+tx.sqlType+')':'')); html += dt('Transmitting') + dd(tx.transmit?'Yes':'No'); } } html += '
'; html += '
'; } } card.innerHTML = html; return card; } // ===== Remote (MQTT) tab: provision + show this node's broker credentials ===== function loadMqtt() { mqttLoaded = true; var pane = document.getElementById('mqttPane'); fetch('/auth/me.php', { credentials: 'same-origin' }).then(function(r) { return r.json(); }).then(function(me) { if (me && me.csrf_token) window._csrfToken = me.csrf_token; var call = ((me && me.callsign) || '').toUpperCase(); if (!call) { pane.innerHTML = 'Your account has no callsign set — add one on the My Account tab first.'; return; } pane.innerHTML = ''; var wrap = document.createElement('div'); var head = document.createElement('div'); head.className = 'mb-2'; head.innerHTML = 'Node callsign: ' + esc(call) + ''; var btn = document.createElement('button'); btn.className = 'btn btn-primary btn-sm'; btn.textContent = 'Show / set up credentials'; var creds = document.createElement('div'); creds.className = 'mqtt-creds small mono mt-2'; btn.addEventListener('click', function() { btn.disabled = true; mqttApi({ action: 'mqtt_node_get', callsign: call }).then(function(j) { btn.disabled = false; if (j && j.ok) mqttCreds(call, creds, j); else { creds.style.display = 'block'; creds.textContent = (j && j.error) || 'error'; } }).catch(function() { btn.disabled = false; }); }); wrap.appendChild(head); wrap.appendChild(btn); wrap.appendChild(creds); // Change talkgroup (owner can control their own node) var tgWrap = document.createElement('div'); tgWrap.className = 'mt-3'; tgWrap.style.borderTop = '1px solid var(--line)'; tgWrap.style.paddingTop = '10px'; tgWrap.innerHTML = '
Send a SvxLink DTMF command (remote control must be ON). ' + '91<TG># talk · 910# leave · ' + '92<TG># QSY (929#→TG9 if someone interferes) · ' + '93# follow QSY · 94<TG># monitor · 9*# status
'; var row = document.createElement('div'); row.className = 'd-flex gap-2 align-items-center'; var inp = document.createElement('input'); inp.className = 'form-control form-control-sm mono'; inp.style.maxWidth = '180px'; inp.type = 'text'; inp.placeholder = 'DTMF, e.g. 91240#'; var setb = document.createElement('button'); setb.className = 'btn btn-primary btn-sm'; setb.type = 'button'; setb.textContent = 'Send'; var st = document.createElement('span'); st.className = 'small'; setb.addEventListener('click', function() { var tg = (inp.value || '').trim().toUpperCase(); if (!/^[0-9A-D*#]{1,16}$/.test(tg)) { st.style.color = '#ff5252'; st.textContent = 'Use 0-9, #, * and A-D'; return; } setb.disabled = true; st.style.color = ''; st.textContent = 'Sending…'; mqttApi({ action: 'dtmf', callsign: call, dtmf: tg }).then(function(j) { setb.disabled = false; if (j && j.ok) { st.style.color = '#00e676'; st.textContent = 'Sent: ' + tg; } else { st.style.color = '#ff5252'; st.textContent = (j && j.error) || 'failed'; } }).catch(function() { setb.disabled = false; st.style.color = '#ff5252'; st.textContent = 'request failed'; }); }); row.appendChild(inp); row.appendChild(setb); row.appendChild(st); tgWrap.appendChild(row); wrap.appendChild(tgWrap); pane.appendChild(wrap); }).catch(function() { pane.innerHTML = 'Failed to load.'; }); } function mqttApi(body) { return fetch('/auth/node_cmd_api.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' }, body: JSON.stringify(body) }).then(function(r) { return r.json(); }); } function mqttCopyBtn(label, value) { return ''; } function mqttWireCopy(cont) { cont.querySelectorAll('[data-copy]').forEach(function(b) { b.addEventListener('click', function() { var v = b.getAttribute('data-copy'); var done = function() { var t = b.textContent; b.textContent = 'Copied!'; setTimeout(function() { b.textContent = t; }, 1200); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(v).then(done).catch(function() { mqttCopyFallback(v, done); }); } else { mqttCopyFallback(v, done); } }); }); } function mqttCopyFallback(v, done) { var ta = document.createElement('textarea'); ta.value = v; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e) {} document.body.removeChild(ta); } function mqttCreds(call, cont, d) { cont.style.display = 'block'; if (d.password) { cont.innerHTML = '
Broker: ' + esc(d.broker_host) + ':' + d.broker_port + '
' + '
Username: ' + esc(d.username) + '' + mqttCopyBtn('Copy', d.username) + '
' + '
Password: ' + esc(d.password) + '' + mqttCopyBtn('Copy', d.password) + '
' + '
Paste these into the node dashboard → Admin → Remote Ctrl, then Turn ON.
' + ''; cont.querySelector('[data-regen]').addEventListener('click', function() { mqttProvision(call, cont); }); mqttWireCopy(cont); } else { cont.innerHTML = ''; cont.querySelector('[data-gen]').addEventListener('click', function() { mqttProvision(call, cont); }); } } function mqttProvision(call, cont) { if (!confirm('Generate a NEW MQTT password for ' + call + '? Any old one stops working.')) return; cont.innerHTML = 'Working…'; mqttApi({ action: 'mqtt_node_provision', callsign: call }).then(function(j) { if (j && j.ok) mqttCreds(call, cont, j); else cont.textContent = (j && j.error) || 'provision failed'; }).catch(function() { cont.textContent = 'request failed'; }); } function dt(l) { return '
' + esc(l) + '
'; } function dd(v) { return '
' + esc(String(v)) + '
'; } function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('.profile-tab[data-ptab]').forEach(function(btn) { btn.addEventListener('click', function() { switchProfileTab(btn.dataset.ptab); }); }); var sel = document.getElementById('nodeSelect'); if (sel) { sel.addEventListener('change', function() { showNodes(sel.value); }); } var rv = document.getElementById('btnResendVerify'); if (rv) { rv.addEventListener('click', async function() { var msg = document.getElementById('resendMsg'); rv.disabled = true; if (msg) { msg.style.color = ''; msg.textContent = 'Sending\u2026'; } try { var r = await fetch('/auth/resend_verification.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' } }); var j = await r.json(); if (j && j.ok) { if (msg) { msg.style.color = '#00c864'; msg.textContent = j.already ? 'Already verified.' : 'Verification email sent \u2014 check your inbox.'; } } else { if (msg) { msg.style.color = '#ff8080'; msg.textContent = (j && j.error) || 'Failed'; } rv.disabled = false; } } catch (e) { if (msg) { msg.style.color = '#ff8080'; msg.textContent = 'Failed'; } rv.disabled = false; } }); } }); // --- Reflector authentication (password/cert nodes linked to this account) --- function nauthApi(body) { var post = function () { return fetch('/auth/node_auth_api.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' }, body: JSON.stringify(body) }).then(function (r) { return r.json(); }).catch(function () { return null; }); }; if (window._csrfToken) return post(); return fetch('/auth/me.php', { credentials: 'same-origin' }).then(function (r) { return r.json(); }) .then(function (me) { if (me && me.csrf_token) window._csrfToken = me.csrf_token; return post(); }) .catch(function () { return post(); }); } function loadNodeAuth() { var cont = document.getElementById('nodeAuthList'); if (!cont) return; nauthApi({ action: 'list' }).then(function (j) { if (!j || !j.ok) { cont.innerHTML = '' + esc((j && j.error) || 'Failed to load') + ''; return; } var list = j.nodes || []; var pend = j.pending || []; if (!list.length && !pend.length) { cont.innerHTML = 'No reflector logins or certificates are linked to your account.'; return; } cont.innerHTML = '
' + '
CallsignAuthPasswordActions
'; var tb = cont.querySelector('tbody'); list.forEach(function (n) { var auth = []; if (n.cert) { var expired = n.validTo && (n.validTo * 1000 < Date.now()); auth.push('cert' + (n.validTo ? ' · ' + new Date(n.validTo * 1000).toISOString().slice(0, 10) : '') + '' + (expired ? ' EXPIRED' : '')); if (typeof n.renewals !== 'undefined') auth.push('renewals: ' + (n.renewals | 0) + ''); } if (n.password) auth.push('password'); var tr = document.createElement('tr'); tr.innerHTML = '' + esc(n.node) + '' + '' + auth.join(' ') + '' + '' + (n.password ? 'Loading…' : '—') + '' + ''; tb.appendChild(tr); var actCell = tr.querySelector('.na-act'); // Remove-certificate (self-service) — for a cert linked to your account // by callsign or verified email. Removal goes through the reflector (CA RM). if (n.cert && n.owned) { var rmBtn = document.createElement('button'); rmBtn.type = 'button'; rmBtn.className = 'btn btn-sm btn-outline-warning py-0 me-1'; rmBtn.style.fontSize = '.75rem'; rmBtn.textContent = 'Revoke certificate'; rmBtn.addEventListener('click', function () { if (!confirm('Remove the certificate for ' + n.node + '?\nThe node can then reconnect and submit a new request, which you can accept here.')) return; rmBtn.disabled = true; nauthApi({ action: 'self_revoke', node: n.node }).then(function (r) { if (r && r.ok) { nodeSecLoaded = false; loadNodeAuth(); } else { rmBtn.disabled = false; alert((r && (r.detail || r.error)) || 'Failed'); } }); }); actCell.appendChild(rmBtn); } // Admins — expand the dmr-style editor under the row (all node types). var admBtn = document.createElement('button'); admBtn.type = 'button'; admBtn.className = 'btn btn-sm btn-outline-secondary py-0 me-1'; admBtn.style.fontSize = '.75rem'; admBtn.textContent = 'Admins'; admBtn.addEventListener('click', function () { var nx = tr.nextElementSibling; if (nx && nx.classList.contains('na-adm-sub')) { nx.remove(); return; } var open = tb.querySelector('.na-adm-sub'); if (open) open.remove(); var sub = document.createElement('tr'); sub.className = 'na-adm-sub'; var td = document.createElement('td'); td.colSpan = 4; td.style.background = 'rgba(0,255,255,.04)'; // Node Security never edits admins — not even for site admins; that is // done on the admin account-edit page. Read-only list only. td.appendChild(nauthAdminsEditor(n.node, false)); sub.appendChild(td); tr.after(sub); }); actCell.appendChild(admBtn); if (n.password) { var pwCell = tr.querySelector('.na-pw'); var showPw = function (pw) { pwCell.innerHTML = '' + esc(pw) + '' + mqttCopyBtn('Copy', pw); mqttWireCopy(pwCell); }; nauthApi({ action: 'getpw', node: n.node }).then(function (r) { if (r && r.ok) showPw(r.password); else pwCell.textContent = (r && (r.detail || r.error)) || 'failed'; }).catch(function () { pwCell.textContent = 'request failed'; }); var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-secondary py-0'; btn.style.fontSize = '.75rem'; btn.textContent = 'Reset password'; btn.addEventListener('click', function () { if (!confirm('Generate a NEW reflector password for ' + n.node + '?\nThe old one stops working and the reflector restarts (nodes reconnect).')) return; btn.disabled = true; pwCell.textContent = 'Working…'; nauthApi({ action: 'resetpw', node: n.node }).then(function (r) { btn.disabled = false; if (r && r.ok) showPw(r.password); else pwCell.textContent = (r && (r.detail || r.error)) || 'failed'; }).catch(function () { btn.disabled = false; pwCell.textContent = 'request failed'; }); }); actCell.appendChild(btn); } }); // Pending certificate requests — a node reconnecting after its cert was // removed submits a fresh CSR that lands here. You can accept (issue) it // yourself once an admin has issued the first certificate for the node or // your account email (callsign or email match links it to you). pend.forEach(function (p) { var tr = document.createElement('tr'); tr.innerHTML = '' + esc(p.node) + '' + 'cert request' + (typeof p.renewals !== 'undefined' ? ' renewals: ' + (p.renewals | 0) + '' : '') + '' + '—' + ''; tb.appendChild(tr); var cell = tr.querySelector('.na-act'); if (p.canSelfSign) { var acc = document.createElement('button'); acc.type = 'button'; acc.className = 'btn btn-sm btn-success py-0'; acc.style.fontSize = '.75rem'; acc.textContent = 'Accept / issue'; acc.addEventListener('click', function () { if (!confirm('Issue the certificate for ' + p.node + '?')) return; acc.disabled = true; nauthApi({ action: 'self_sign', node: p.node }).then(function (r) { if (r && r.ok) { nodeSecLoaded = false; loadNodeAuth(); } else { acc.disabled = false; alert((r && (r.detail || r.error)) || 'Failed'); } }); }); cell.appendChild(acc); } else { cell.innerHTML = 'Waiting for an admin to issue the first certificate.'; } }); }); } // Per-node admins editor — exactly the dmr peer "Edit admins" table: Add on // top, a row per admin with an editable callsign field + Delete, Save/Back at // the bottom. Each node keeps its own admin list (node_admins:), so // admins differ node by node. On Save the edited list is diffed against the // server and add/remove calls are issued. Editing is site-admin only — // regular users get a read-only list (the server enforces the same rule). function nauthAdminsEditor(cs, editable) { var wrap = document.createElement('div'); wrap.className = 'mt-2'; wrap.innerHTML = '
' + (editable ? 'Node admins — add a callsign to give them admin rights on this node. Each node has its own admins.' : 'Node admins for this node. Only site admins can change the list.') + '
'; var box = wrap.querySelector('.ae-box'); box.innerHTML = 'Loading…'; naApi({ action: 'list', node: cs }).then(function (j) { if (!j || !j.ok) { box.innerHTML = '' + esc((j && j.error) || 'failed') + ''; return; } var server = (j.admins || []).map(function (c) { return String(c).toUpperCase(); }); if (!editable) { var ro = '
'; if (!server.length) ro += ''; server.forEach(function (c) { ro += ''; }); box.innerHTML = ro + '
Callsign
No extra admins. (The node\'s own callsign always counts.)
' + esc(c) + '
'; return; } var working = server.slice(); box.innerHTML = '
' + '
' + '
' + ''; var tw = box.querySelector('.ae-wrap'), msg = box.querySelector('.ae-msg'); var setErr = function (t, ok) { msg.style.display = t ? '' : 'none'; msg.style.color = ok ? '#00ff80' : '#ff8080'; msg.textContent = t || ''; }; var renderTable = function () { var html = '
' + ''; if (!working.length) html += ''; working.forEach(function (v, i) { html += '' + ''; }); tw.innerHTML = html + '
CallsignAction
No admins — click Add to grant a callsign admin rights for this node. (Your own callsign always counts.)
'; tw.querySelectorAll('.ae-cs').forEach(function (inp) { inp.addEventListener('input', function () { working[+inp.dataset.i] = inp.value; }); }); tw.querySelectorAll('.ae-del').forEach(function (b) { b.addEventListener('click', function () { working.splice(+b.dataset.i, 1); renderTable(); }); }); }; renderTable(); box.querySelector('.ae-add').addEventListener('click', function () { working.push(''); renderTable(); }); box.querySelector('.ae-save').addEventListener('click', function () { var seen = {}, norm = []; working.forEach(function (c) { c = String(c).trim().toUpperCase(); if (c && !seen[c]) { seen[c] = 1; norm.push(c); } }); var toRemove = server.filter(function (c) { return norm.indexOf(c) < 0; }); var toAdd = norm.filter(function (c) { return server.indexOf(c) < 0; }); setErr('Saving…', true); var chain = Promise.resolve(true); toRemove.forEach(function (c) { chain = chain.then(function (ok) { return ok && naApi({ action: 'remove', node: cs, admin: c }).then(function (r) { if (!r || !r.ok) { setErr((r && r.error) || ('Failed to remove ' + c), false); return false; } return true; }); }); }); toAdd.forEach(function (c) { chain = chain.then(function (ok) { return ok && naApi({ action: 'add', node: cs, admin: c }).then(function (r) { if (!r || !r.ok) { setErr((r && r.error) || ('Failed to add ' + c), false); return false; } return true; }); }); }); chain.then(function (ok) { if (ok) { server = norm.slice(); setErr('✓ Saved', true); } }); }); }); return wrap; } // --- Node admin management (add/remove) — owner / node admin / site admin --- function naApi(body) { var post = function () { return fetch('/auth/node_admins_api.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' }, body: JSON.stringify(body) }).then(function (r) { return r.json(); }).catch(function () { return null; }); }; // The CSRF token is only guaranteed once me.php has been fetched; ensure it's set. if (window._csrfToken) return post(); return fetch('/auth/me.php', { credentials: 'same-origin' }).then(function (r) { return r.json(); }) .then(function (me) { if (me && me.csrf_token) window._csrfToken = me.csrf_token; return post(); }) .catch(function () { return post(); }); }