Files
reflector-dashboard/tgmon_view.js
T

197 lines
9.5 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';
// 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 `<span class="badge ${map[m] || 'bg-info text-dark'}">${escHtml(m)}</span>`;
}
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 = '<div class="text-secondary">No clients on this talkgroup right now.</div>';
} 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 => `<tr><td class="mono"><b>${escHtml(n.callsign)}</b></td><td class="mono">${escHtml(m)}</td>` +
`<td>${sideBadge(n.side)}</td><td>${escHtml(n.location)}</td></tr>`).join('')
).join('');
inner = `<table class="table table-sm"><thead><tr><th>Callsign</th><th>Mode</th><th>Side</th><th>Location</th></tr></thead><tbody>${rows}</tbody></table>`;
}
body.innerHTML =
`<div class="d-flex justify-content-between align-items-center mb-2">` +
`<h5 class="mb-0">${tgLabel(_sel)} <span class="text-secondary">— ${nodes.length} node${nodes.length === 1 ? '' : 's'}</span></h5>` +
`<button class="btn btn-sm btn-outline-secondary" id="tgmonBack">&larr; Back</button></div>${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 = '<div class="text-secondary">' +
(q ? 'No match — no such TG, and no online callsign/node like that right now.'
: 'No talkgroups with active clients.') + '</div>';
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 =
'<table class="table table-sm"><thead><tr><th>Talkgroup</th><th>Nodes total</th></tr></thead><tbody>' +
slice.map(e =>
`<tr class="tgmon-row" data-tg="${e.tg}" style="cursor:pointer;"><td><b>${tgLabel(e.tg)}</b></td><td>${e.nodes.length}</td></tr>`
).join('') + '</tbody></table>' + 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(); } });
})();