108 lines
4.4 KiB
JavaScript
108 lines
4.4 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';
|
|
|
|
let __lhInflight = false;
|
|
|
|
async function refreshLastheard(){
|
|
if (__lhInflight) return;
|
|
__lhInflight = true;
|
|
try {
|
|
const r = await apiFetch('/svxportal/api/lastheard?limit=200'); // lib/api.js
|
|
if(!r.ok || !r.json){
|
|
renderLastheard({events:[], count:0, _err:`http ${r.status}`});
|
|
return;
|
|
}
|
|
const j = r.json;
|
|
renderLastheard(j);
|
|
} catch(e){
|
|
renderLastheard({events:[], count:0, _err:String(e)});
|
|
} finally {
|
|
__lhInflight = false;
|
|
}
|
|
}
|
|
|
|
function renderLastheard(payload){
|
|
const tbody = document.getElementById('lastheardRows');
|
|
const cnt = document.getElementById('lastheardCount');
|
|
if(!tbody) return;
|
|
|
|
const evs = (payload?.events || []).map(e => {
|
|
if (typeof e === 'string'){ try{ return JSON.parse(e); }catch{ return null; } }
|
|
return e;
|
|
}).filter(Boolean);
|
|
|
|
if (cnt) cnt.textContent = evs.length ? `${evs.length} event${evs.length===1?'':'s'} / last 24h` :
|
|
(payload?._err ? `error: ${payload._err}` : 'no events yet');
|
|
|
|
if (evs.length === 0){
|
|
tbody.innerHTML = `<tr><td colspan="8" class="text-secondary">No keyups recorded in last 24h.</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
// Network bridge directory (crawled from every reflector): so a bridge that
|
|
// arrives bare over a trunk (hub reflector: node/system empty) renders IDENTICALLY to
|
|
// where it is a local client (bridge box: astmeta filled node/system). Same code,
|
|
// same result on both boxes.
|
|
const bn = (window.__svxportal_lastStatus && window.__svxportal_lastStatus.bridge_nodes) || {};
|
|
const now = Math.floor(Date.now()/1000);
|
|
tbody.innerHTML = evs.map(e => {
|
|
const ts = Number(e.ts || 0);
|
|
const age = now - ts;
|
|
const ageStr = formatAge(age);
|
|
const absTs = new Date(ts*1000).toLocaleString();
|
|
const dur = Number(e.duration_s || 0);
|
|
const durStr = dur >= 60 ? `${Math.floor(dur/60)}m${dur%60}s` : `${dur}s`;
|
|
const sig = (e.siglev !== undefined && e.siglev !== null && e.siglev !== 0)
|
|
? ` <span class="text-secondary mono">${Number(e.siglev).toFixed(0)}</span>` : '';
|
|
const rx = e.rx_name ? `${escHtml(e.rx_name)}${sig}` : '<span class="text-secondary">-</span>';
|
|
const bi = bn[String(e.callsign || '').toUpperCase()];
|
|
const eSystem = e.system || (bi && bi.system) || '';
|
|
// Backend = HOW the talker enters (AllStar / SVXLink / trunk / DMR / PBX …),
|
|
// the ingress path — same concept the DMR side calls "Backend" (there "Mode"
|
|
// means the DV protocol, a different fact). Carried in the network field.
|
|
const eBackend = e.network || (bi && bi.network) || '';
|
|
// Callsign column = the callsign field as-is (e.g. the trunk callsign
|
|
// KT4ROY-ASL). The gateway/reflector descriptor ("East Coast Reflector via
|
|
// 45225 / W2ECR") lives in the Location column. Uniform on every box: an
|
|
// AllStar row (bridge box) and the same talker arriving over the trunk (hub)
|
|
// render identically — the backend already fills callsign + location, the
|
|
// web just shows them, it never re-derives the name.
|
|
const csCell = `<b>${escHtml(e.callsign || eSystem || '')}</b>`;
|
|
return `<tr>
|
|
<td class="mono nowrap" title="${escHtml(absTs)}">${ageStr}</td>
|
|
<td class="mono">${csCell}</td>
|
|
<td class="wrap">${escHtml(eBackend)}</td>
|
|
<td class="mono nowrap">${Number(e.tg||0)}</td>
|
|
<td class="wrap">${escHtml(e.location || '')}</td>
|
|
<td class="wrap">${rx}</td>
|
|
<td class="mono nowrap">${durStr}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function formatAge(sec){
|
|
if (sec < 0) sec = 0;
|
|
if (sec < 60) return `${sec}s ago`;
|
|
if (sec < 3600) return `${Math.floor(sec/60)}m ago`;
|
|
if (sec < 86400) return `${Math.floor(sec/3600)}h ago`;
|
|
const d = Math.floor(sec/86400);
|
|
return `${d}d ago`;
|
|
}
|
|
|
|
function escHtml(s){
|
|
return String(s).replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
|
|
window.refreshLastheard = refreshLastheard;
|
|
window.renderLastheard = renderLastheard;
|