197 lines
9.5 KiB
JavaScript
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">← 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(); } });
|
|
})();
|