Files
reflector-dashboard/monitor_view.js
T
bjorneandClaude Fable 5.1 25b481e2ae Public web-only release: SvxPortal front-end + auth backend, install templates and INSTALL.md
Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr),
deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles
live in install/ with CHANGEME placeholders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 23:07:05 +02:00

129 lines
5.7 KiB
JavaScript
Raw 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.
'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(); });
})();