// 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 += ``; else html += nodeRow(r.n); } html += `
— no nodes —
` + 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(); }); })();