138 lines
6.2 KiB
JavaScript
138 lines
6.2 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';
|
||
/*
|
||
* 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(); });
|
||
})();
|