// SvxPortal — 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 = `No keyups recorded in last 24h.`; 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) ? ` ${Number(e.siglev).toFixed(0)}` : ''; const rx = e.rx_name ? `${escHtml(e.rx_name)}${sig}` : '-'; 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 = `${escHtml(e.callsign || eSystem || '')}`; return ` ${ageStr} ${csCell} ${escHtml(eBackend)} ${Number(e.tg||0)} ${escHtml(e.location || '')} ${rx} ${durStr} `; }).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;