// 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'; // TG Monitor — vilka klienter finns på varje TG, per mode + sida (SVX/DMR). // Symmetrisk motsvarighet till DMR-webbens TG Monitor. Läser status-noderna // (network.reflectors[].nodes + network.trunks[].nodes): reflektor-/lokala noder // = SVX-sidan, trunk-noder = DMR-sidan (kom in via satellite-trunken). Dedupar // per (tg, side, callsign) så en callsign kan finnas på flera TG och båda sidor // utan att kollapsa (den globala callsign-keyingen i nodelist gör annars det). // Bara TG med >=1 nod. Klick på en TG öppnar en egen detaljvy. Uppdateras live // av status-refreshen (render() anropar renderTgMon när panelen är synlig). (function () { let _sel = null; // vald TG (detaljvy) eller null (lista) let _page = 1; // sida i TG-listan let _tgNames = {}; // tg -> name, from the canonical /tglist.php function tgName(tg) { return _tgNames[tg] || ''; } // "TG 3149050 — East Coast Reflector" when the list names the TG, else "TG n". function tgLabel(tg) { const n = tgName(tg); return 'TG ' + tg + (n ? ' — ' + escHtml(n) : ''); } // Rader/sida anpassas till FAKTISKT utrymme under tabellen (mät var den börjar) // så den får plats utan att scrolla; ta i lite (footer-pager + rubrik). // En bryggnod (chan_svx-asterisk, DMR/trunk-brygga …) är ingen riktig klient — // den kopplar bara ihop system och gör listan missvisande. Känns igen på sw // (chan_svx) eller nod-namnets brygg-suffix (-AC/-DMR/-T). Gäller ALLA sådana, // både lokala och fjärran (t.ex. KT4ROY:s asterisk) — vi tillskriver dem bara // aldrig fel ägare. const BRIDGE_SUFFIX = /-(AC|DMR|T)$/i; function isBridge(n) { if (!n) return true; const sw = String(n.sw || '').toLowerCase(); // chan_svx_sat = a real AllStar node over the satellite trunk → KEEP it. if (sw.indexOf('chan_svx_sat') !== -1) return false; if (sw.indexOf('chan_svx') !== -1) return true; // plain chan_svx = reflector bridge const nm = String(n.name || n.node || n.id || ''); if (nm && BRIDGE_SUFFIX.test(nm)) return true; return false; } // The real mode/type of a node, derived from its software (svx nodes have no // "mode" field like the dmr side does). SVX / AllStar / DMR / YSF / … function modeOf(n) { const sw = String((n && n.sw) || '').toLowerCase(); if (sw.indexOf('chan_svx_sat') !== -1) return 'AllStar'; if (sw.indexOf('chan_svx') !== -1) return 'AllStar'; if (sw.indexOf('svxlink') !== -1) return 'SVX'; if (sw.indexOf('mmdvm') !== -1 || sw.indexOf('brandmeister') !== -1 || sw.indexOf('dmr') !== -1) return 'DMR'; if (sw.indexOf('ysf') !== -1) return 'YSF'; if (sw.indexOf('nxdn') !== -1) return 'NXDN'; if (sw.indexOf('m17') !== -1) return 'M17'; return (n && n.sw) ? String(n.sw) : 'SVX'; } // Samla alla noder → {tg, callsign, mode, side, location}. En nod läggs under // sin nuvarande TG OCH varje monitored TG (så "lyssnar på TG x" räknas med). // Bryggnoder hoppas över (bara riktiga radioklienter). function collect() { const st = window.__svxportal_lastStatus || {}; const net = st.network || {}; const out = []; const push = (n) => { if (!n || isBridge(n)) return; const cs = n.callsign || ''; if (!cs) return; const m = modeOf(n); // real type from sw: SVX / AllStar / DMR / … const tgs = new Set(); if (Number(n.tg) > 0) tgs.add(Number(n.tg)); if (Array.isArray(n.monitoredTGs)) n.monitoredTGs.forEach(t => { if (Number(t) > 0) tgs.add(Number(t)); }); tgs.forEach(tg => out.push({ tg, callsign: cs, mode: m, side: m, location: n.nodeLocation || n.location || '' })); }; (Array.isArray(net.reflectors) ? net.reflectors : []).forEach(r => { if (!r || !Array.isArray(r.nodes)) return; if (r.type === 'local' || (r.type === 'reflector' && r.online && !r.firewalled)) r.nodes.forEach(n => push(n)); }); (Array.isArray(net.trunks) ? net.trunks : []).forEach(t => { if (!t || !Array.isArray(t.nodes)) return; t.nodes.forEach(n => push(n)); }); return out; } // Gruppera per TG, dedupa per (tg, side, callsign). Bara TG med noder. function byTg() { const map = new Map(); for (const n of collect()) { if (!map.has(n.tg)) map.set(n.tg, new Map()); const key = n.side + '|' + n.callsign; if (!map.get(n.tg).has(key)) map.get(n.tg).set(key, n); } const arr = [...map.entries()].map(([tg, m]) => ({ tg, nodes: [...m.values()] })) .filter(e => e.nodes.length > 0); arr.sort((a, b) => a.tg - b.tg); return arr; } function sideBadge(m) { const map = { SVX:'bg-info text-dark', AllStar:'bg-warning text-dark', DMR:'bg-secondary', YSF:'bg-success', NXDN:'bg-primary', M17:'bg-danger' }; return `${escHtml(m)}`; } function renderTgMon() { const body = document.getElementById('tgmonBody'); if (!body) return; const entries = byTg(); if (_sel != null) { const t = entries.find(e => e.tg === _sel); const nodes = t ? t.nodes : []; let inner; if (!nodes.length) { inner = '
No clients on this talkgroup right now.
'; } else { const byMode = {}; nodes.forEach(n => { (byMode[n.mode || '?'] = byMode[n.mode || '?'] || []).push(n); }); const rows = Object.keys(byMode).sort().map(m => byMode[m].slice().sort((a, b) => String(a.callsign).localeCompare(String(b.callsign))) .map(n => `${escHtml(n.callsign)}${escHtml(m)}` + `${sideBadge(n.side)}${escHtml(n.location)}`).join('') ).join(''); inner = `${rows}
CallsignModeSideLocation
`; } body.innerHTML = `
` + `
${tgLabel(_sel)} — ${nodes.length} node${nodes.length === 1 ? '' : 's'}
` + `
${inner}`; const back = document.getElementById('tgmonBack'); if (back) back.addEventListener('click', () => { _sel = null; renderTgMon(); }); return; } // Search: a TG number (who's on TG x) OR a callsign/node (which TG(s) it's on). const q = ((document.getElementById('tgmonSearch') || {}).value || '').trim().toLowerCase(); let list = entries; if (q) list = entries.filter(e => String(e.tg).includes(q) || tgName(e.tg).toLowerCase().includes(q) || e.nodes.some(n => String(n.callsign || '').toLowerCase().includes(q))); if (!list.length) { body.innerHTML = '
' + (q ? 'No match — no such TG, and no online callsign/node like that right now.' : 'No talkgroups with active clients.') + '
'; return; } const ps = pageSizeFor('tgmonBody'); const pages = Math.max(1, Math.ceil(list.length / ps)); if (_page > pages) _page = pages; if (_page < 1) _page = 1; const slice = list.slice((_page - 1) * ps, (_page - 1) * ps + ps); body.innerHTML = '' + slice.map(e => `` ).join('') + '
TalkgroupNodes total
${tgLabel(e.tg)}${e.nodes.length}
' + pagerHtml({ page: _page, pages, total: list.length, noun: 'TGs', cls: 'tgm-pg' }); body.querySelectorAll('.tgmon-row').forEach(tr => tr.addEventListener('click', () => { _sel = Number(tr.dataset.tg) || null; renderTgMon(); })); body.querySelectorAll('.tgm-pg[data-pg]').forEach(b => b.addEventListener('click', () => { const p = parseInt(b.dataset.pg, 10); if (p >= 1 && p <= pages && p !== _page) { _page = p; renderTgMon(); } })); } window.renderTgMon = renderTgMon; // Canonical TG names from the shared list (/tglist.php). Best-effort: if it // fails the monitor still works, just with bare TG numbers. Refreshed on load; // names change rarely so a per-page-load fetch is enough. (function loadTgNames() { fetch('/tglist.php', { cache: 'no-store' }) .then(r => r.ok ? r.json() : null) .then(j => { if (!j || !Array.isArray(j.tgs)) return; const m = {}; j.tgs.forEach(t => { if (t && t.tg) m[t.tg] = t.name || t.system || ''; }); _tgNames = m; if (document.getElementById('tgmonPanel')?.style.display !== 'none') renderTgMon(); }) .catch(() => {}); })(); // Re-fit rows-per-page on resize. window.addEventListener('resize', () => { if (document.getElementById('tgmonPanel')?.style.display !== 'none') renderTgMon(); }); // Live search (TG number or callsign/node). document.addEventListener('input', (e) => { if (e.target && e.target.id === 'tgmonSearch') { _page = 1; renderTgMon(); } }); })();