Files
Cort BuffingtonandClaude Opus 4.8 ad4e0ed13a reporting: mirror dashboard normalization to dmrlink3 (Step 3)
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>
2026-07-12 10:00:33 -05:00

652 lines
33 KiB
HTML
Raw Permalink 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.
<!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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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">&#8212; idle &#8212;</div>'
+ '<div class="spill-bot">&nbsp;</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>'
+ ' &nbsp;<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">&#8212; no active streams &#8212;</div><div class="spill-bot">&nbsp;</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>'
+ ' &nbsp;<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>