// 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';
/*
* Monitor → Nodes. Read ONE reflector /status (via nodelist /api/monitor-nodes)
* that already has the whole picture, and show it grouped: a "Local" section
* first, then one section per trunk with its nodes underneath. No recursive
* crawl. Borderless. Search filters callsign/node or TG across every section.
* Footer pager (first « · ‹ · page numbers · › · last ») pages through the whole
* thing, rows/page fit to the screen so it never scrolls — same as TG Monitor.
*/
(function () {
let data = { local: [], trunks: [] };
let timer = null;
let started = false;
let _page = 1;
const $ = (id) => document.getElementById(id);
function modeBadge(m) {
if (!m) return '';
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)}`;
}
async function fetchData() {
try {
const j = await apiGet('/svxportal/api/monitor-nodes'); // lib/api.js
if (j) { data = j; render(); }
} catch (_) {}
}
function filterNodes(nodes, q, mode) {
let ns = nodes || [];
// "All active" = only nodes on a real (non-zero) talkgroup; "All nodes" = all.
if (mode === 'active') ns = ns.filter(n => (Number(String(n.tg || '').replace('*', '')) || 0) > 0);
if (q) ns = ns.filter(n =>
String(n.callsign || '').toLowerCase().includes(q) ||
String(n.tg || '').toLowerCase().includes(q));
return ns;
}
// Rows/page fit ACTUAL space under the table (measure where it starts) so it
// fits without scrolling; leave room for the footer pager + heading.
function nodeRow(n) {
return `
`
+ `| ${escHtml(n.callsign)} | `
+ `${modeBadge(n.mode)} | `
+ `${escHtml(n.tg || '')} | `
+ `${escHtml(n.nodeClass || '')} | `
+ `
`;
}
function headRow(title, sub) {
// A thin theme-aware line UNDER the section name (Local / trunk) only, so the
// sections read apart at a glance. Nothing else gets a border.
return `| ${escHtml(title)}`
+ (sub ? ` ${escHtml(sub)}` : '')
+ ` |
`;
}
function render() {
const body = $('monitorBody');
if (!body) return;
const q = (($('monitorSearch') || {}).value || '').trim().toLowerCase();
const mode = (($('monitorMode') || {}).value || 'all');
const filt = (q || mode === 'active'); // a filter is active
// Build one flat row list (section headers + node rows) so a single footer
// pager pages through Local + every trunk and it fits the screen.
const rows = []; // {kind:'head'|'node'|'empty', ...}
let nodeCount = 0;
const loc = filterNodes(data.local, q, mode);
if (loc.length || !filt) {
rows.push({ kind: 'head', title: 'Local', sub: `${(data.local || []).length} nodes` });
if (loc.length) { loc.forEach(n => rows.push({ kind: 'node', n })); nodeCount += loc.length; }
else rows.push({ kind: 'empty' });
}
for (const t of (data.trunks || [])) {
const tn = filterNodes(t.nodes, q, mode);
if (filt && !tn.length) continue; // hide empty sections while filtering
const sub = [`${(t.nodes || []).length} nodes`, t.connected ? '' : 'offline'].filter(Boolean).join(' · ');
rows.push({ kind: 'head', title: t.name, sub });
if (tn.length) { tn.forEach(n => rows.push({ kind: 'node', n })); nodeCount += tn.length; }
else rows.push({ kind: 'empty' });
}
if (!rows.length) { body.innerHTML = 'Loading…
'; return; }
if (filt && nodeCount === 0) { body.innerHTML = 'No node or TG matches the filter.
'; return; }
const ps = pageSizeFor('monitorBody');
const pages = Math.max(1, Math.ceil(rows.length / ps));
if (_page > pages) _page = pages;
if (_page < 1) _page = 1;
const slice = rows.slice((_page - 1) * ps, (_page - 1) * ps + ps);
let html = ``;
for (const r of slice) {
if (r.kind === 'head') html += headRow(r.title, r.sub);
else if (r.kind === 'empty') html += `| — no nodes — |
`;
else html += nodeRow(r.n);
}
html += `
` + pagerHtml({ page: _page, pages, total: nodeCount, noun: 'nodes', cls: 'mon-pg' });
body.innerHTML = html;
body.querySelectorAll('.mon-pg[data-pg]').forEach(b => b.addEventListener('click', () => {
const p = parseInt(b.dataset.pg, 10);
if (p >= 1 && p <= pages && p !== _page) { _page = p; render(); }
}));
}
// Called by app.js (nav click + every status poll while the panel is visible).
window.renderMonitor = function () {
if (!started) {
started = true;
fetchData();
if (!timer) timer = setInterval(() => { if ($('monitorPanel')?.style.display !== 'none') fetchData(); }, 5000);
}
render();
};
// Live search (callsign/node or TG number) + mode toggle (All nodes / All active).
document.addEventListener('input', (e) => { if (e.target && e.target.id === 'monitorSearch') { _page = 1; render(); } });
document.addEventListener('change', (e) => { if (e.target && e.target.id === 'monitorMode') { _page = 1; render(); } });
// Re-fit rows-per-page on resize.
window.addEventListener('resize', () => { if ($('monitorPanel')?.style.display !== 'none') render(); });
})();