Files

138 lines
6.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 `<span class="badge ${map[m] || 'bg-info text-dark'}">${escHtml(m)}</span>`;
}
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 `<tr>`
+ `<td class="mono fw-bold">${escHtml(n.callsign)}</td>`
+ `<td>${modeBadge(n.mode)}</td>`
+ `<td class="mono nowrap">${escHtml(n.tg || '')}</td>`
+ `<td class="text-secondary small">${escHtml(n.nodeClass || '')}</td>`
+ `</tr>`;
}
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 `<tr class="mon-head"><td colspan="4" class="fw-bold pt-3 pb-1">${escHtml(title)}`
+ (sub ? ` <span class="text-secondary small">${escHtml(sub)}</span>` : '')
+ `</td></tr>`;
}
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 = '<div class="text-secondary">Loading…</div>'; return; }
if (filt && nodeCount === 0) { body.innerHTML = '<div class="text-secondary">No node or TG matches the filter.</div>'; 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 = `<table class="table table-sm table-borderless mb-0"><colgroup><col style="width:34%"><col style="width:14%"><col style="width:16%"><col style="width:36%"></colgroup><tbody>`;
for (const r of slice) {
if (r.kind === 'head') html += headRow(r.title, r.sub);
else if (r.kind === 'empty') html += `<tr><td colspan="4" class="text-secondary small">— no nodes —</td></tr>`;
else html += nodeRow(r.n);
}
html += `</tbody></table>` + 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(); });
})();