mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 04:19:24 +00:00
Ports hblink3's dashboard-reporting overhaul to dmrlink3's IPSC path so the
two converge (plan: ~/development/dashboard-reporting-normalization.md):
- Granular peer events: a centralized reconciliation sweep in both the master
and peer maintenance loops (report_maintenance) diffs currently-connected
peers vs the last-reported set and emits send_peer connected/disconnected
deltas only on change -- the dashboard reflects membership immediately
instead of waiting for the periodic full push. (The single master link-state,
in PEER mode, continues to ride the snapshot.)
- Demote the full push: reporting_loop now calls periodic_push, which re-pushes
the full config+bridge snapshot only every REPORT_RESYNC_SECONDS (60s) as a
reconciliation safety net and sends a tiny {'type':'ping'} heartbeat on the
other ticks. Idle feed stops re-serializing full state every REPORT_INTERVAL.
- Ping-loss quality metric (PING_LOSS): detects a peer/master that STAYS
CONNECTED but loses keepalives (lossy link -> choppy audio). One uniform
gap-inference path serves both roles -- MASTER mode observes peers'
MASTER_ALIVE_REQs, PEER mode observes the master's/peers' *_ALIVE_REPLYs to
our requests. IPSC's cadence is known (ALIVE_TIMER) so no self-calibration is
needed (unlike hblink3). A gap beyond the keepalive-timeout horizon is treated
as a session lapse (reconnect), not loss. Sliding window pruned to
PING_LOSS_WINDOW minutes; dashboard flags the row gold at >= PING_LOSS_WARN%.
Config: PING_LOSS_WINDOW / PING_LOSS_WARN added to GLOBAL (optional, defaults).
Dashboard: backend applies peer deltas + ignores the ping heartbeat; frontend
renders "N% ping loss" and golds the radio id. Tests: TestPeerEvents +
TestPingLoss (73 pass). Verified live over real sockets (peer delta fired,
heartbeat/resync split confirmed, PING_LOSS climbed to 30% under a lossy peer)
and deployed to the live dmrlink3 services clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
652 lines
33 KiB
HTML
652 lines
33 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{REPORT_NAME}} — DMRlink3</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0f172a; --panel:#1e293b; --panel2:#172033; --border:#334155;
|
||
--text:#e2e8f0; --muted:#94a3b8; --accent:#38bdf8;
|
||
--green:#16a34a; --green-bg:#16351f; --red:#dc2626; --red-bg:#3a1620;
|
||
--orange:#ea580c; --orange-bg:#3a2412; --warn:#fbbf24;
|
||
}
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);padding:14px;font-size:13px}
|
||
.wrap{max-width:1400px;margin:0 auto}
|
||
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px}
|
||
h1{color:var(--accent);font-size:22px;font-weight:600}
|
||
h1 small{color:var(--muted);font-size:13px;font-weight:400;margin-left:8px}
|
||
.badge{padding:6px 14px;border-radius:6px;font-weight:600;font-size:13px;border:1px solid var(--border)}
|
||
.badge.up{background:var(--green-bg);color:#4ade80;border-color:var(--green)}
|
||
.badge.down{background:var(--red-bg);color:#f87171;border-color:var(--red)}
|
||
section{margin-bottom:22px}
|
||
h2{font-size:15px;color:var(--accent);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px}
|
||
h3{font-size:13px;color:var(--muted);margin:12px 0 6px}
|
||
table{width:100%;border-collapse:collapse;background:var(--panel);border-radius:8px;overflow:hidden;margin-bottom:12px}
|
||
th{background:var(--panel2);color:var(--muted);text-align:left;padding:8px 10px;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border)}
|
||
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||
tr:last-child td{border-bottom:none}
|
||
.sub{color:var(--muted);font-size:11px}
|
||
.mono{font-variant-numeric:tabular-nums}
|
||
.sysname{font-weight:600;color:var(--accent)}
|
||
.bridge-head td{background:var(--panel2);color:#fff;font-weight:600;font-size:12px;letter-spacing:.02em;padding-top:9px;padding-bottom:9px}
|
||
.pill{display:inline-block;padding:2px 7px;border-radius:10px;font-size:10px;border:1px solid var(--border);color:var(--muted)}
|
||
.pill.master{border-color:var(--accent);color:var(--accent)}
|
||
.pill.peer{border-color:#818cf8;color:#818cf8}
|
||
.pill.trunk{border-color:#a78bfa;color:#a78bfa}
|
||
.trunk-streams{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
|
||
.status-ok{color:#4ade80;font-weight:600}
|
||
.status-bad{color:#f87171;font-weight:600}
|
||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
||
.dot.ok{background:#22c55e}.dot.bad{background:#ef4444}
|
||
.empty{color:var(--muted);padding:10px;background:var(--panel);border-radius:8px;text-align:center}
|
||
#log{background:#0b1220;border:1px solid var(--border);border-radius:8px;height:230px;overflow-y:auto;padding:8px 10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5}
|
||
#log .start{color:#7dd3fc}#log .end{color:#86efac}#log .ts{color:#64748b}
|
||
/* system-level TS activity pills */
|
||
.sys-hdr{display:flex;justify-content:space-between;align-items:center;margin:12px 0 4px}
|
||
.sys-hdr h3{margin:0}
|
||
.peers-hdr{margin:4px 0 6px}
|
||
.slot-pills{display:flex;gap:8px;flex-shrink:0}
|
||
.slot-col{display:flex;flex-direction:column;align-items:flex-start}
|
||
.slot-label{font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px}
|
||
.spill{width:280px;border-radius:6px;padding:5px 10px;font-size:12px;border:1px solid var(--border);box-sizing:border-box}
|
||
.spill-top{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.4}
|
||
.spill-bot{font-size:11px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.4;opacity:.85}
|
||
.spill.idle{color:var(--muted);background:var(--panel2)}
|
||
.spill.idle .spill-top{font-weight:400}
|
||
.spill.rx{background:var(--green-bg);color:#86efac;border-color:var(--green)}
|
||
.spill.tx{background:var(--orange-bg);color:#fb923c;border-color:var(--orange)}
|
||
.spill.hang{background:#0d2137;color:#7dd3fc;border-color:#075985}
|
||
.brand{display:flex;align-items:center;gap:14px}
|
||
.brand .logo{height:46px;width:auto;display:block}
|
||
.sys-table{table-layout:fixed}
|
||
.sys-table th:nth-child(1),.sys-table td:nth-child(1){width:22%}
|
||
.sys-table th:nth-child(2),.sys-table td:nth-child(2){width:42%}
|
||
.sys-table th:nth-child(3),.sys-table td:nth-child(3){width:36%}
|
||
.lh-head{display:flex;align-items:center;gap:10px}
|
||
.toggle-btn{cursor:pointer;user-select:none;display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--accent);background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:3px 10px;text-transform:none;letter-spacing:0}
|
||
.toggle-btn:hover{border-color:var(--accent)}
|
||
.toggle-btn .caret{font-size:11px;line-height:1}
|
||
.sec-body.hidden{display:none}
|
||
.peers-hdr .toggle-btn .sub{font-weight:400}
|
||
.sys-block.collapsed .sys-table,.sys-block.collapsed .empty{display:none}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<div class="brand">
|
||
{{LOGO_HTML}}
|
||
<h1>{{REPORT_NAME}}<small>DMRlink3 Monitor</small></h1>
|
||
</div>
|
||
<div id="conn" class="badge down">DMRlink3: connecting…</div>
|
||
</header>
|
||
|
||
<section id="lastHeardSection"><h2 class="lh-head"><span id="lastHeardTitle">Last Heard</span> <span id="lastHeardToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Active only</span></span></h2><div id="lastHeardBody"><div id="lastHeard"></div></div></section>
|
||
<section id="systemsSection"><h2 class="lh-head">Systems <span id="systemsToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="systemsBody" class="sec-body"><div id="systems"></div></div></section>
|
||
<section id="bridgesSection"><h2 class="lh-head">Conference Bridges <span id="bridgesToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="bridgesBody" class="sec-body"><div id="bridges"></div></div></section>
|
||
<section id="logSection"><h2 class="lh-head">Call Log <span id="logToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="logBody" class="sec-body"><div id="log"></div></div></section>
|
||
</div>
|
||
|
||
<script>
|
||
const ST = { systems:{}, bridges:{}, active:{}, tx_active:{}, hang:{}, trunk_streams:{}, log:[], dmrlink:false, ping_loss_warn:5 };
|
||
const STALE = 300000; // backstop: drop an active call this many ms after START if no END arrives (well above amateur TOT)
|
||
const LAST_HEARD_CFG = '{{LAST_HEARD}}'; // 'open' | 'closed' | 'off'
|
||
const LAST_HEARD_COUNT = parseInt('{{LAST_HEARD_COUNT}}', 10) || 10;
|
||
const SYSTEM_PEERS_CFG = '{{SYSTEM_PEERS}}'; // 'open' | 'closed' — default state for per-system peer lists
|
||
|
||
const el = id => document.getElementById(id);
|
||
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
|
||
function dur(secs){
|
||
secs = Math.max(0, Math.floor(secs));
|
||
const d=Math.floor(secs/86400), h=Math.floor(secs%86400/3600), m=Math.floor(secs%3600/60), s=secs%60;
|
||
if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; if(m) return m+'m '+s+'s'; return s+'s';
|
||
}
|
||
const idName = (name, id) => name ? esc(name)+' <span class="sub mono">('+id+')</span>' : '<span class="mono">'+esc(String(id||''))+'</span>';
|
||
|
||
// one active call per system+timeslot (IPSC only allows one per TS)
|
||
const ckey = e => (e.system||'')+'|'+(e.ts||0);
|
||
|
||
// ---- system-level TS activity pills ----
|
||
|
||
// Render one TS pill for a system: RX (receiving from a peer) or TX (forwarding to system) or idle.
|
||
function sysTsPill(sys, ts){
|
||
const rx = ST.active[sys+'|'+ts];
|
||
const tx = ST.tx_active[sys+'|'+ts];
|
||
function espan(e){ const s=(Date.now()/1000)-(e._arrived||0); return '<span class="js-dur" data-start="'+esc(String(e._arrived||0))+'">'+dur(s)+'</span>'; }
|
||
function callType(e){
|
||
const ev = e.event||'';
|
||
if(ev.includes('GROUP VOICE')) return 'Group Voice';
|
||
if(ev.includes('UNIT VOICE')) return 'Unit Voice';
|
||
if(ev.includes('GROUP DATA')) return 'Group Data';
|
||
if(ev.includes('UNIT DATA')) return 'Unit Data';
|
||
return 'Voice';
|
||
}
|
||
if(rx){
|
||
const src = rx.src_alias ? esc(rx.src_alias) : esc(String(rx.src||''));
|
||
const peer = rx.peer_alias ? esc(rx.peer_alias) : esc(String(rx.peer||''));
|
||
const tg = rx.tgid_alias ? esc(rx.tgid_alias) : esc(String(rx.tgid||''));
|
||
return '<div class="spill rx">'
|
||
+ '<div class="spill-top">'+src+' → '+tg+'</div>'
|
||
+ '<div class="spill-bot">RX · '+callType(rx)+' · via '+peer+' · '+espan(rx)+'</div>'
|
||
+ '</div>';
|
||
}
|
||
if(tx){
|
||
const src = tx.src_alias ? esc(tx.src_alias) : esc(String(tx.src||''));
|
||
const src_peer = tx.src_peer_alias ? esc(tx.src_peer_alias) : esc(String(tx.src_peer||''));
|
||
const tg = tx.tgid_alias ? esc(tx.tgid_alias) : esc(String(tx.tgid||''));
|
||
return '<div class="spill tx">'
|
||
+ '<div class="spill-top">'+src+' → '+tg+'</div>'
|
||
+ '<div class="spill-bot">TX · '+callType(tx)+' · via '+src_peer+' · '+espan(tx)+'</div>'
|
||
+ '</div>';
|
||
}
|
||
const hg = ST.hang[sys+'|'+ts];
|
||
if(hg){
|
||
const tg = hg.tgid_alias ? esc(hg.tgid_alias) : esc(String(hg.tgid||''));
|
||
const nowS = Date.now()/1000;
|
||
const left = Math.max(0, hg._expires - nowS);
|
||
return '<div class="spill hang">'
|
||
+ '<div class="spill-top">'+(hg._ht==='tx'?'TX':'RX')+' hang → '+tg+'</div>'
|
||
+ '<div class="spill-bot"><span class="js-countdown" data-rem="'+left.toFixed(1)+'" data-arr="'+Date.now()+'">'+dur(left)+'</span> remaining</div>'
|
||
+ '</div>';
|
||
}
|
||
return '<div class="spill idle">'
|
||
+ '<div class="spill-top">— idle —</div>'
|
||
+ '<div class="spill-bot"> </div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
// ---- trunk stream pill (identical spill card, no slot label) ----
|
||
|
||
function trunkStreamPill(stream){
|
||
const src = stream.src_alias ? esc(stream.src_alias) : '<span class="mono">'+esc(String(stream.src||''))+'</span>';
|
||
const tg = stream.tgid_alias ? esc(stream.tgid_alias) : '<span class="mono">'+esc(String(stream.tgid||''))+'</span>';
|
||
const sid = esc(String(stream.stream_id||'').slice(0,8));
|
||
const elapsed = '<span class="js-dur" data-start="'+esc(String(stream._arrived||0))+'">'+dur(Date.now()/1000-(stream._arrived||0))+'</span>';
|
||
const cls = stream.direction==='rx' ? 'rx' : 'tx';
|
||
const dir = stream.direction==='rx' ? 'RX' : 'TX';
|
||
return '<div class="spill '+cls+'">'
|
||
+ '<div class="spill-top">'+src+' → '+tg+'</div>'
|
||
+ '<div class="spill-bot">'+dir+' · Group Voice · <span class="mono">'+sid+'</span> · '+elapsed+'</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
// ---- renderers ----
|
||
|
||
function renderTrunkBlock(n, s){
|
||
const streams = Object.values(ST.trunk_streams).filter(t => t.system===n);
|
||
let h = '<div class="sys-hdr">'
|
||
+ '<h3><span class="sysname">'+esc(n)+'</span>'
|
||
+ ' <span class="pill trunk">TRUNK</span>'
|
||
+ ' <span class="sub">peer '+esc(s.PEER_IP||'?')+':'+esc(String(s.PEER_PORT||'?'))+'</span></h3>'
|
||
+ '</div>';
|
||
h += '<div class="peers-hdr"><h3>Active Streams ('+streams.length+')</h3></div>';
|
||
h += '<div class="trunk-streams">';
|
||
if(!streams.length){
|
||
h += '<div class="spill idle"><div class="spill-top">— no active streams —</div><div class="spill-bot"> </div></div>';
|
||
} else {
|
||
for(const st of streams) h += trunkStreamPill(st);
|
||
}
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
|
||
// ---- per-system peer-list collapse (persisted per browser; SYSTEM_PEERS sets the default) ----
|
||
function sysCollapsedStore(){ try{ return JSON.parse(localStorage.getItem('dmrl_sys_collapsed')||'{}'); }catch(e){ return {}; } }
|
||
function sysIsCollapsed(n){
|
||
const st = sysCollapsedStore();
|
||
return Object.prototype.hasOwnProperty.call(st, n) ? !!st[n] : (SYSTEM_PEERS_CFG==='closed');
|
||
}
|
||
function sysSetCollapsed(n, val){
|
||
const st = sysCollapsedStore(); st[n] = val;
|
||
localStorage.setItem('dmrl_sys_collapsed', JSON.stringify(st));
|
||
}
|
||
function initSystemsCollapse(){
|
||
// Delegated so it survives renderSystems() rebuilding #systems on every event.
|
||
el('systems').addEventListener('click', ev=>{
|
||
const btn = ev.target.closest('.peers-toggle'); if(!btn) return;
|
||
const block = btn.closest('.sys-block'); if(!block) return;
|
||
const n = block.dataset.sys;
|
||
sysSetCollapsed(n, !sysIsCollapsed(n));
|
||
renderSystems();
|
||
});
|
||
}
|
||
|
||
function renderSystems(){
|
||
const names = Object.keys(ST.systems);
|
||
if(!names.length){ el('systems').innerHTML='<div class="empty">No systems (dmrlink3 not connected or no networks enabled)</div>'; return; }
|
||
let h='';
|
||
for(const n of names){
|
||
const s = ST.systems[n];
|
||
if(s.SYSTEM_TYPE==='TRUNK'){ h += renderTrunkBlock(n, s); continue; }
|
||
const mode = s.MODE||'PEER';
|
||
|
||
// Peer table — identical columns for both MASTER and PEER mode systems
|
||
const peers = s.PEERS||{};
|
||
const pids = Object.keys(peers);
|
||
const ms = mode === 'PEER' ? (s.MASTER||{}) : null;
|
||
const totalCount = pids.length + (ms ? 1 : 0);
|
||
|
||
h += '<div class="sys-hdr">'
|
||
+ '<h3><span class="sysname">'+esc(n)+'</span>'
|
||
+ ' <span class="pill '+(mode==='MASTER'?'master':'peer')+'">'+esc(mode)+'</span>'
|
||
+ ' <span class="sub">Radio ID: '+esc(s.RADIO_ID)+' | '+esc(s.IP||'0.0.0.0')+':'+esc(s.PORT)+'</span></h3>'
|
||
+ '<div class="slot-pills">'
|
||
+ '<div class="slot-col"><div class="slot-label">TS1</div>'+sysTsPill(n,1)+'</div>'
|
||
+ '<div class="slot-col"><div class="slot-label">TS2</div>'+sysTsPill(n,2)+'</div>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
const collapsed = sysIsCollapsed(n);
|
||
h += '<div class="sys-block'+(collapsed?' collapsed':'')+'" data-sys="'+esc(n)+'">';
|
||
h += '<div class="peers-hdr"><span class="toggle-btn peers-toggle" title="Show or hide this system’s peers">'
|
||
+ '<span class="caret">'+(collapsed?'▸':'▾')+'</span>'+(collapsed?'Show Peers':'Hide Peers')
|
||
+ ' <span class="sub">('+totalCount+')</span></span></div>';
|
||
|
||
if(!totalCount){
|
||
h += '<div class="empty" style="margin-bottom:10px">No peers</div>';
|
||
} else {
|
||
h += '<table class="sys-table"><tr><th>Radio ID</th><th>Address</th><th>Status</th></tr>';
|
||
|
||
// Inject master as first row when we are a PEER — the master does not
|
||
// include itself in the peer list it sends us, so we synthesise the row.
|
||
if(ms){
|
||
const mrid = ms.RADIO_ID != null ? ms.RADIO_ID : '—';
|
||
const mok = ms.CONNECTED;
|
||
const mUp = mok && ms.connected_secs != null
|
||
? '<div class="sub">up <span class="js-since" data-base="'+ms.connected_secs+'" data-arr="'+Date.now()+'">'+dur(ms.connected_secs)+'</span></div>'
|
||
: '';
|
||
// Keepalive-loss quality: PING_LOSS is the % lost over the last window.
|
||
const mLoss = ms.PING_LOSS || 0;
|
||
const mridStyle = mLoss >= ST.ping_loss_warn ? ' style="color:var(--warn)"' : '';
|
||
const mLossTag = mLoss > 0 ? '<div class="sub">· '+mLoss+'% ping loss</div>' : '';
|
||
h += '<tr>'
|
||
+ '<td class="mono"'+mridStyle+'>'+esc(mrid)+mLossTag+'</td>'
|
||
+ '<td class="mono">'+esc(ms.IP||'—')+':'+esc(ms.PORT||'—')+mUp+'</td>'
|
||
+ '<td><span class="dot '+(mok?'ok':'bad')+'"></span>'
|
||
+ '<span class="'+(mok?'status-ok':'status-bad')+'">'+(mok?'Connected':'Disconnected')+'</span>'
|
||
+ ' <span class="pill master">master</span></td>'
|
||
+ '</tr>';
|
||
}
|
||
|
||
const masterRid = ms ? ms.RADIO_ID : null;
|
||
for(const pid of pids){
|
||
const p = peers[pid];
|
||
const p2p = p.CONNECTED;
|
||
const rid = p.RADIO_ID != null ? p.RADIO_ID : pid;
|
||
// Self is shown in the system header; skip it from the peer membership table.
|
||
if(String(rid) === String(s.RADIO_ID)) continue;
|
||
// Skip if master happens to also appear in the peer list (avoid duplicate row).
|
||
if(masterRid != null && String(rid) === String(masterRid)) continue;
|
||
|
||
// Status: MASTER-mode CONNECTED is reliable (peer registered directly with us).
|
||
// PEER-mode: presence in the peer list is the meaningful status; P2P keepalive
|
||
// may not be supported by all endpoints, shown only as a secondary badge.
|
||
let statusHtml;
|
||
if(mode === 'MASTER'){
|
||
statusHtml = '<span class="dot '+(p2p?'ok':'bad')+'"></span>'
|
||
+ '<span class="'+(p2p?'status-ok':'status-bad')+'">'+(p2p?'Connected':'Disconnected')+'</span>';
|
||
} else {
|
||
statusHtml = '<span class="dot ok"></span>'
|
||
+ '<span class="status-ok">Connected</span>';
|
||
}
|
||
|
||
const pUp = p.connected_secs != null
|
||
? '<div class="sub">up <span class="js-since" data-base="'+p.connected_secs+'" data-arr="'+Date.now()+'">'+dur(p.connected_secs)+'</span></div>'
|
||
: '';
|
||
const pLoss = p.PING_LOSS || 0;
|
||
const pridStyle = pLoss >= ST.ping_loss_warn ? ' style="color:var(--warn)"' : '';
|
||
const pLossTag = pLoss > 0 ? '<div class="sub">· '+pLoss+'% ping loss</div>' : '';
|
||
h += '<tr>'
|
||
+ '<td class="mono"'+pridStyle+'>'+esc(rid)+pLossTag+'</td>'
|
||
+ '<td class="mono">'+esc(p.IP)+':'+esc(p.PORT)+pUp+'</td>'
|
||
+ '<td>'+statusHtml+'</td>'
|
||
+ '</tr>';
|
||
}
|
||
h += '</table>';
|
||
}
|
||
h += '</div>'; // close .sys-block (collapsible peer list)
|
||
}
|
||
el('systems').innerHTML = h;
|
||
}
|
||
|
||
function renderBridges(){
|
||
const names = Object.keys(ST.bridges);
|
||
if(!names.length){ el('bridges').innerHTML='<div class="empty">No bridge data (is bridge.py running?)</div>'; return; }
|
||
let h='<table><tr><th>System</th><th>TS</th><th>TGID</th><th>Status</th><th>Timeout</th><th>Timer</th><th>Timeout Action</th><th>Activate TGIDs</th><th>Deactivate TGIDs</th></tr>';
|
||
for(const b of names){
|
||
h += '<tr class="bridge-head"><td colspan="9">'+esc(b)+'</td></tr>';
|
||
for(const m of ST.bridges[b]){
|
||
const on = m.ACTIVE;
|
||
const timerRunning = (m.TO_TYPE==='ON' && m.ACTIVE) || (m.TO_TYPE==='OFF' && !m.ACTIVE);
|
||
const remS = timerRunning ? m.TIMER - Date.now()/1000 : null;
|
||
const rem = remS!=null ? '<span class="js-countdown mono" data-rem="'+remS.toFixed(1)+'" data-arr="'+Date.now()+'">'+(remS<=0?'expired':dur(remS))+'</span>'
|
||
: '<span class="sub">N/A</span>';
|
||
const act = m.TO_TYPE==='ON'?'Disconnect after timer':m.TO_TYPE==='OFF'?'Reconnect after timer':'<span class="sub">None</span>';
|
||
h += '<tr>'
|
||
+ '<td class="sysname">'+esc(m.SYSTEM)+'</td>'
|
||
+ '<td class="mono">'+esc(m.TS)+'</td>'
|
||
+ '<td class="mono">'+idName(m.TGID_NAME, m.TGID)+'</td>'
|
||
+ '<td><span class="'+(on?'status-ok':'status-bad')+'">'+(on?'Active':'Inactive')+'</span></td>'
|
||
+ '<td class="mono sub">'+(m.TO_TYPE!=='NONE'?Math.round(m.TIMEOUT/60)+'m':'<span class="sub">N/A</span>')+'</td>'
|
||
+ '<td>'+rem+'</td>'
|
||
+ '<td class="sub">'+act+'</td>'
|
||
+ '<td class="mono sub">'+(m.ON||[]).join(', ')+'</td>'
|
||
+ '<td class="mono sub">'+(m.OFF||[]).join(', ')+'</td>'
|
||
+ '</tr>';
|
||
}
|
||
}
|
||
el('bridges').innerHTML = h+'</table>';
|
||
}
|
||
|
||
function logRow(e){
|
||
const t = new Date((e._arrived||0)*1000).toLocaleTimeString();
|
||
const ev = e.event||'';
|
||
const isStart = ev.includes('START');
|
||
const cls = isStart ? 'start' : 'end';
|
||
const d = e.duration != null ? ' · '+e.duration.toFixed(1)+'s' : '';
|
||
const src = e.src_alias ? esc(e.src_alias)+' ('+e.src+')' : esc(String(e.src||''));
|
||
const tg = e.tgid_alias ? esc(e.tgid_alias)+' ('+e.tgid+')' : esc(String(e.tgid||''));
|
||
return '<div><span class="ts">'+t+'</span> <span class="'+cls+'">'+esc(ev)+'</span>'
|
||
+ ' · '+esc(e.system||'')
|
||
+ ' TS'+esc(e.ts||'?')
|
||
+ ' · '+src+' → '+tg+d+'</div>';
|
||
}
|
||
function renderLog(){ el('log').innerHTML = ST.log.map(logRow).join(''); }
|
||
|
||
// ---------- last heard ----------
|
||
// Most-recent unique callers, derived from the ingress (non-TX) call log — no
|
||
// server state of its own. Deduped by source radio ID, newest first.
|
||
// The call type is carried inside the compound 'event' string
|
||
// (e.g. "GROUP VOICE START"); strip the direction/action tokens to label it.
|
||
function callTypeLabel(ev){
|
||
if(!ev) return '';
|
||
const t = ev.replace(/\s*(TX\s+)?(START|END)\s*$/i, '').trim();
|
||
return t.split(/\s+/).map(w => w ? w[0].toUpperCase()+w.slice(1).toLowerCase() : w).join(' ');
|
||
}
|
||
function lhActiveSrc(src){ for(const k in ST.active){ if(String(ST.active[k].src)===String(src)) return true; } return false; }
|
||
function renderLastHeard(){
|
||
const box = el('lastHeard'); if(!box) return;
|
||
const seen = {}, rows = [];
|
||
for(const e of ST.log){
|
||
if((e.event||'').includes('TX')) continue; // skip retransmit legs; keep true originators
|
||
const id = e.src==null ? '' : String(e.src);
|
||
if(!id || seen[id]) continue;
|
||
if(lastHeardCollapsed && !lhActiveSrc(e.src)) continue; // "Active Calls" view: live streams only
|
||
seen[id] = 1; rows.push(e);
|
||
if(rows.length >= LAST_HEARD_COUNT) break;
|
||
}
|
||
if(!rows.length){ box.innerHTML = '<div class="empty">'+(lastHeardCollapsed?'No active calls':'No calls heard yet')+'</div>'; return; }
|
||
let h = '<table><tr><th>User</th><th>Type</th><th>Source</th><th>TS</th><th>Talkgroup</th><th>When</th></tr>';
|
||
for(const e of rows){
|
||
const t = e._arrived||0; // seconds
|
||
const when = lhActiveSrc(e.src)
|
||
? '<span class="status-ok">Active</span>'
|
||
: '<span class="js-lh mono" data-t="'+t+'">'+dur(Date.now()/1000 - t)+' ago</span>';
|
||
h += '<tr><td>'+idName(e.src_alias,e.src)+'</td>'
|
||
+ '<td class="sub">'+esc(callTypeLabel(e.event))+'</td>'
|
||
+ '<td class="mono sub">'+(e.peer!=null ? idName(e.peer_alias,e.peer) : esc(e.system||''))+'</td>'
|
||
+ '<td class="mono">TS'+esc(e.ts||'?')+'</td>'
|
||
+ '<td>'+idName(e.tgid_alias,e.tgid)+'</td>'
|
||
+ '<td>'+when+'</td></tr>';
|
||
}
|
||
box.innerHTML = h+'</table>';
|
||
}
|
||
// Collapsing Last Heard doesn't hide it — it filters to an "Active Calls" view
|
||
// (live streams only) and retitles the header. Expanded shows the full list.
|
||
let lastHeardCollapsed = false;
|
||
function applyLastHeard(collapsed){
|
||
lastHeardCollapsed = collapsed;
|
||
el('lastHeardTitle').textContent = collapsed ? 'Active Calls' : 'Last Heard';
|
||
const t = el('lastHeardToggle');
|
||
t.querySelector('.caret').textContent = collapsed ? '▸' : '▾';
|
||
t.querySelector('.toggle-label').textContent = collapsed ? 'Show all' : 'Active only';
|
||
renderLastHeard();
|
||
}
|
||
function initLastHeard(){
|
||
const sec = el('lastHeardSection'); if(!sec) return;
|
||
if(LAST_HEARD_CFG==='off'){ sec.style.display='none'; return; }
|
||
const saved = localStorage.getItem('lastHeardOpen'); // '1' = full list, '0' = active-only; else config default
|
||
applyLastHeard(saved==null ? (LAST_HEARD_CFG==='closed') : (saved==='0'));
|
||
el('lastHeardToggle').addEventListener('click', ()=>{
|
||
const collapsed = !lastHeardCollapsed;
|
||
applyLastHeard(collapsed);
|
||
localStorage.setItem('lastHeardOpen', collapsed?'0':'1');
|
||
});
|
||
}
|
||
// Simple collapsible sections (hide the body). Persisted per browser.
|
||
function initCollapse(key, bodyId, toggleId, defCollapsed){
|
||
const body = el(bodyId), btn = el(toggleId);
|
||
if(!body || !btn) return;
|
||
const apply = c => {
|
||
body.classList.toggle('hidden', c);
|
||
btn.querySelector('.caret').textContent = c ? '▸' : '▾';
|
||
btn.querySelector('.toggle-label').textContent = c ? 'Show' : 'Hide';
|
||
};
|
||
const saved = localStorage.getItem('collapse:'+key); // '1' = collapsed, '0' = shown; else default
|
||
apply(saved==null ? !!defCollapsed : saved==='1');
|
||
btn.addEventListener('click', ()=>{
|
||
const collapsed = !body.classList.contains('hidden'); // currently shown → we're collapsing
|
||
localStorage.setItem('collapse:'+key, collapsed?'1':'0');
|
||
apply(collapsed);
|
||
});
|
||
}
|
||
|
||
// ---- ticker: countdown timers + slot pill elapsed ----
|
||
setInterval(()=>{
|
||
const now = Date.now();
|
||
let reaped = false;
|
||
for(const k in ST.active) { if(now - (ST.active[k]._arrived||0)*1000 > STALE){ delete ST.active[k]; reaped=true; } }
|
||
for(const k in ST.tx_active){ if(now - (ST.tx_active[k]._arrived||0)*1000 > STALE){ delete ST.tx_active[k]; reaped=true; } }
|
||
for(const k in ST.hang) { if(now/1000 > ST.hang[k]._expires) { delete ST.hang[k]; reaped=true; } }
|
||
if(reaped){ renderSystems(); renderLog(); }
|
||
document.querySelectorAll('.js-countdown').forEach(s=>{
|
||
const r = (+s.dataset.rem) - (now - (+s.dataset.arr))/1000;
|
||
s.textContent = r<=0?'expired':dur(r);
|
||
});
|
||
document.querySelectorAll('.js-dur').forEach(s=>{
|
||
s.textContent = dur(now/1000 - (+s.dataset.start||0));
|
||
});
|
||
document.querySelectorAll('.js-since').forEach(s=>{
|
||
s.textContent = dur((+s.dataset.base) + (now - (+s.dataset.arr))/1000);
|
||
});
|
||
document.querySelectorAll('.js-lh').forEach(s=>{
|
||
s.textContent = dur(now/1000 - (+s.dataset.t)) + ' ago';
|
||
});
|
||
}, 1000);
|
||
|
||
// ---- websocket ----
|
||
function setConn(up){
|
||
ST.dmrlink = up;
|
||
const c = el('conn');
|
||
c.className = 'badge '+(up?'up':'down');
|
||
c.textContent = 'DMRlink3: '+(up?'connected':'disconnected');
|
||
}
|
||
function renderAll(){ renderSystems(); renderBridges(); renderLog(); renderLastHeard(); }
|
||
|
||
function applyBridgeEvent(data){
|
||
data._arrived = data._arrived || (Date.now()/1000);
|
||
const ev = data.event||'';
|
||
const key = ckey(data);
|
||
const ht = (ST.systems[data.system]||{}).GROUP_HANGTIME||0;
|
||
if(ev==='GROUP VOICE START'){
|
||
delete ST.hang[key];
|
||
ST.active[key] = data;
|
||
} else if(ev==='GROUP VOICE END'){
|
||
delete ST.active[key];
|
||
if(ht>0) ST.hang[key] = Object.assign({}, data, {_ht:'rx', _expires: data._arrived+ht});
|
||
} else if(ev==='GROUP VOICE TX START'){
|
||
delete ST.hang[key];
|
||
ST.tx_active[key] = data;
|
||
} else if(ev==='GROUP VOICE TX END'){
|
||
delete ST.tx_active[key];
|
||
if(ht>0) ST.hang[key] = Object.assign({}, data, {_ht:'tx', _expires: data._arrived+ht});
|
||
}
|
||
ST.log.unshift(data);
|
||
if(ST.log.length > 400) ST.log.pop();
|
||
renderSystems();
|
||
renderLog();
|
||
renderLastHeard();
|
||
}
|
||
|
||
let sock = null;
|
||
function connect(){
|
||
sock = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||
sock.onmessage = ev => {
|
||
const m = JSON.parse(ev.data);
|
||
if(m.type==='initial'){
|
||
setConn(m.dmrlink);
|
||
if(m.ping_loss_warn!=null) ST.ping_loss_warn = m.ping_loss_warn;
|
||
ST.systems = m.systems||{};
|
||
ST.bridges = m.bridges||{};
|
||
ST.active = {};
|
||
ST.tx_active = {};
|
||
ST.hang = {};
|
||
(m.active||[]).forEach(e => { e._arrived = e._arrived || (Date.now()/1000); ST.active[ckey(e)] = e; });
|
||
ST.log = (m.log||[]);
|
||
renderAll();
|
||
}
|
||
else if(m.type==='dmrlink'){
|
||
setConn(m.connected);
|
||
if(!m.connected){ ST.systems={}; ST.bridges={}; ST.active={}; ST.tx_active={}; ST.hang={}; ST.trunk_streams={}; renderAll(); }
|
||
}
|
||
else if(m.type==='config'){
|
||
if(m.ping_loss_warn!=null) ST.ping_loss_warn = m.ping_loss_warn;
|
||
ST.systems = m.systems||{};
|
||
renderSystems();
|
||
}
|
||
else if(m.type==='bridge'){
|
||
ST.bridges = m.bridges||{};
|
||
renderBridges();
|
||
}
|
||
else if(m.type==='bridge_event'){
|
||
applyBridgeEvent(m.data||{});
|
||
}
|
||
};
|
||
sock.onclose = () => {
|
||
setConn(false);
|
||
el('conn').textContent = 'DMRlink3: dashboard offline';
|
||
setTimeout(connect, 2000);
|
||
};
|
||
}
|
||
// ---------------------------------------------------------------------------
|
||
// Demo mode (?demo in URL) — coherent static scenario, no WebSocket.
|
||
// Traffic paradigm:
|
||
// KS-STATEWIDE (TGID 9, TS1): W0ABC keyed up on KS-MASTER →
|
||
// KS-MASTER TS1 RX, MO-PEER TS1 TX, TRUNK-EAST TX stream
|
||
// US-NATIONWIDE (TGID 31, TS2): N0CAL transmitting via TRUNK-EAST →
|
||
// TRUNK-EAST RX stream, KS-MASTER TS2 TX
|
||
// MO-LOCAL (TGID 3100, TS2): W0DEF just finished; MO-PEER TS2 in RX hang
|
||
// ---------------------------------------------------------------------------
|
||
if(new URLSearchParams(location.search).has('demo')){
|
||
const _t = Date.now()/1000;
|
||
|
||
ST.systems = {
|
||
'KS-MASTER': {
|
||
MODE:'MASTER', RADIO_ID:'3112000', IP:'0.0.0.0', PORT:50100, GROUP_HANGTIME:5,
|
||
PEERS:{
|
||
'3112001':{ RADIO_ID:'3112001', IP:'10.1.1.1', PORT:50000, CONNECTED:true, connected_secs:3672 },
|
||
'3112002':{ RADIO_ID:'3112002', IP:'10.1.1.2', PORT:50000, CONNECTED:true, connected_secs:1245 },
|
||
'3112003':{ RADIO_ID:'3112003', IP:'10.1.1.3', PORT:50000, CONNECTED:false, connected_secs:null },
|
||
},
|
||
},
|
||
'MO-PEER': {
|
||
MODE:'PEER', RADIO_ID:'3161000', IP:'0.0.0.0', PORT:50101, GROUP_HANGTIME:5,
|
||
MASTER:{ RADIO_ID:'3112000', CONNECTED:true, connected_secs:7234, IP:'10.0.0.1', PORT:'50100' },
|
||
PEERS:{
|
||
'3161001':{ RADIO_ID:'3161001', IP:'10.2.1.1', PORT:50000, CONNECTED:true, connected_secs:2480 },
|
||
'3161002':{ RADIO_ID:'3161002', IP:'10.2.1.2', PORT:50000, CONNECTED:true, connected_secs:847 },
|
||
},
|
||
},
|
||
'TRUNK-EAST': {
|
||
SYSTEM_TYPE:'TRUNK', RADIO_ID:'3129100',
|
||
IP:'0.0.0.0', PORT:50200, PEER_IP:'198.51.100.42', PEER_PORT:50200,
|
||
},
|
||
};
|
||
|
||
ST.bridges = {
|
||
'KS-STATEWIDE':[
|
||
{ SYSTEM:'KS-MASTER', TS:1, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||
{ SYSTEM:'MO-PEER', TS:1, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||
{ SYSTEM:'TRUNK-EAST',TS:1, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||
],
|
||
'US-NATIONWIDE':[
|
||
{ SYSTEM:'KS-MASTER', TS:2, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||
{ SYSTEM:'TRUNK-EAST', TS:2, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||
],
|
||
'MO-LOCAL':[
|
||
{ SYSTEM:'MO-PEER', TS:2, TGID:3100, TGID_NAME:'MO Local', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||
],
|
||
};
|
||
|
||
// W0ABC keyed up on KS Statewide 23s ago
|
||
ST.active = {
|
||
'KS-MASTER|1':{ system:'KS-MASTER', ts:1, event:'GROUP VOICE START',
|
||
src:3112001, src_alias:'W0ABC', peer:3112001, peer_alias:'W0ABC',
|
||
tgid:9, tgid_alias:'KS Statewide', _arrived:_t-23 },
|
||
};
|
||
// MO-PEER TS1 bridging that call; KS-MASTER TS2 receiving from trunk
|
||
ST.tx_active = {
|
||
'MO-PEER|1': { system:'MO-PEER', ts:1, event:'GROUP VOICE TX START',
|
||
src:3112001, src_alias:'W0ABC', src_peer:3112001, src_peer_alias:'W0ABC',
|
||
tgid:9, tgid_alias:'KS Statewide', _arrived:_t-23 },
|
||
'KS-MASTER|2':{ system:'KS-MASTER', ts:2, event:'GROUP VOICE TX START',
|
||
src:3188765, src_alias:'N0CAL', src_peer:3188765, src_peer_alias:'N0CAL',
|
||
tgid:31, tgid_alias:'US Nationwide', _arrived:_t-11 },
|
||
};
|
||
// Trunk streams matching both active flows
|
||
ST.trunk_streams = {
|
||
'ab12cd34':{ system:'TRUNK-EAST', stream_id:'ab12cd34', direction:'tx',
|
||
src:3112001, src_alias:'W0ABC', tgid:9, tgid_alias:'KS Statewide', _arrived:_t-23 },
|
||
'ef56ab78':{ system:'TRUNK-EAST', stream_id:'ef56ab78', direction:'rx',
|
||
src:3188765, src_alias:'N0CAL', tgid:31, tgid_alias:'US Nationwide', _arrived:_t-11 },
|
||
};
|
||
// MO-PEER TS2 in RX hang — W0DEF finished 3s ago, 5s hangtime → ~27s left from load
|
||
ST.hang = {
|
||
'MO-PEER|2':{ system:'MO-PEER', ts:2, tgid:3100, tgid_alias:'MO Local',
|
||
_ht:'rx', _expires:_t+27, _arrived:_t-3 },
|
||
};
|
||
// Call log, most-recent first
|
||
ST.log = [
|
||
{ event:'GROUP VOICE TX START', system:'KS-MASTER', ts:2,
|
||
src:3188765, src_alias:'N0CAL', tgid:31, tgid_alias:'US Nationwide', _arrived:_t-11 },
|
||
{ event:'GROUP VOICE TX START', system:'MO-PEER', ts:1,
|
||
src:3112001, src_alias:'W0ABC', tgid:9, tgid_alias:'KS Statewide', _arrived:_t-23 },
|
||
{ event:'GROUP VOICE START', system:'KS-MASTER', ts:1,
|
||
src:3112001, src_alias:'W0ABC', peer:3112001, peer_alias:'W0ABC',
|
||
tgid:9, tgid_alias:'KS Statewide', _arrived:_t-23 },
|
||
{ event:'GROUP VOICE END', system:'MO-PEER', ts:2,
|
||
src:3161001, src_alias:'W0DEF', peer:3161001, peer_alias:'W0DEF',
|
||
tgid:3100, tgid_alias:'MO Local', duration:14.2, _arrived:_t-3 },
|
||
{ event:'GROUP VOICE START', system:'MO-PEER', ts:2,
|
||
src:3161001, src_alias:'W0DEF', peer:3161001, peer_alias:'W0DEF',
|
||
tgid:3100, tgid_alias:'MO Local', _arrived:_t-17.2 },
|
||
{ event:'GROUP VOICE TX END', system:'MO-PEER', ts:1,
|
||
src:3112002, src_alias:'KD0XYZ', tgid:9, tgid_alias:'KS Statewide',
|
||
duration:8.7, _arrived:_t-129 },
|
||
{ event:'GROUP VOICE END', system:'KS-MASTER', ts:1,
|
||
src:3112002, src_alias:'KD0XYZ', peer:3112002, peer_alias:'KD0XYZ',
|
||
tgid:9, tgid_alias:'KS Statewide', duration:8.7, _arrived:_t-129 },
|
||
{ event:'GROUP VOICE TX START', system:'MO-PEER', ts:1,
|
||
src:3112002, src_alias:'KD0XYZ', tgid:9, tgid_alias:'KS Statewide', _arrived:_t-137.7 },
|
||
{ event:'GROUP VOICE START', system:'KS-MASTER', ts:1,
|
||
src:3112002, src_alias:'KD0XYZ', peer:3112002, peer_alias:'KD0XYZ',
|
||
tgid:9, tgid_alias:'KS Statewide', _arrived:_t-137.7 },
|
||
];
|
||
|
||
setConn(true);
|
||
renderAll();
|
||
// No WebSocket in demo mode — setInterval ticker still runs for live timers.
|
||
} else {
|
||
connect();
|
||
}
|
||
initLastHeard();
|
||
initSystemsCollapse();
|
||
initCollapse('systems','systemsBody','systemsToggle', false);
|
||
initCollapse('bridges','bridgesBody','bridgesToggle', false);
|
||
initCollapse('log','logBody','logToggle', false);
|
||
</script>
|
||
</body>
|
||
</html>
|