Files
Cort BuffingtonandClaude Opus 4.8 1c463fb52b reporting: normalize event vocabulary + add Unix-socket transport (Step 5)
Canonical event names across the reporting feed, matching hblink4's emit()
vocabulary (the OmniLink north star):
  * peer            -> peer_connected / peer_disconnected
  * stream          -> stream_start / stream_end
The lifecycle now rides the message `type`; the `action` field is retained
where consumers still use it (log label, END duration/hang handling).

Unix-socket transport for a same-box dashboard, alongside the existing TCP:
  * daemon: REPORT_TRANSPORT=unix binds start_unix_server on REPORT_SOCKET
    (stale socket file cleared first); TCP stays the default for remote dashboards.
  * dashboard: HBLINK_TRANSPORT=unix connects via open_unix_connection(HBLINK_SOCKET).
A local Unix socket can't NIC-flap or be conntrack-evicted, retiring the whole
silent-severance class for local dashboards (filesystem perms replace the IP ACL;
TCP keepalive is moot).

Also: ping-cadence calibration now takes the MEDIAN of the first PING_WARMUP
gaps (robust to an odd first interval), counting loss only once the cadence
is known.

Tests updated for the new vocabulary; fixes an unbounded-loop test bug in
test_loss_registers_then_ages_out (the window-fill loop's bound tracked the
list it was appending to -> infinite append -> OOM).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 12:14:23 -05:00

487 lines
32 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}} — HBlink3</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:1600px;margin:0 auto}
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px}
.brand{display:flex;align-items:center;gap:14px}
.brand .logo{height:46px;width:auto;display:block}
h1{color:var(--accent);font-size:24px;font-weight:600}
h1 small{color:var(--muted);font-size:14px;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)}
.call{border-radius:5px;padding:5px 8px;min-height:30px;width:100%;box-sizing:border-box;overflow:hidden}
.call.idle{color:#475569;background:var(--panel2);border:1px solid transparent}
.call.rx{background:var(--green-bg);color:#86efac;border:1px solid var(--green)} /* receive */
.call.tx{background:var(--red-bg);color:#fca5a5;border:1px solid var(--red)} /* transmit */
.call.hang{background:#0d2137;color:#7dd3fc;border:1px solid #075985}
.call.rx .sub,.call.tx .sub,.call.hang .sub{color:inherit;opacity:.85}
.call .call-top{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.call b{color:var(--text)}
.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}
.slotcol{width:255px}
.sys-row{display:flex;justify-content:space-between;align-items:center;margin:12px 0 4px}
.sys-row h3{margin:0}
.ts-hdrs{display:flex;flex-shrink:0}
.ts-hdr{width:255px;padding-left:10px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;box-sizing:border-box}
.sys-block .sys-pills{display:none} /* system pills hidden while expanded */
.sys-block.collapsed .sys-pills{display:flex;justify-content:flex-end;margin:0 0 12px}
.sys-block.collapsed table{display:none} /* hide repeater table when collapsed */
.sys-pills .slotcol{padding-left:10px;box-sizing:border-box}
.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}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
{{LOGO_HTML}}
<h1>{{REPORT_NAME}}<small>HBlink3 Monitor</small></h1>
</div>
<div id="conn" class="badge down">HBlink: 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="serversSection"><h2 class="lh-head">Server Systems <span id="serversToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="serversBody" class="sec-body"><div id="servers"></div></div></section>
<section id="outboundsSection"><h2 class="lh-head">Outbound Systems <span id="outboundsToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="outboundsBody" class="sec-body"><div id="outbounds"></div></div></section>
<section id="openbridgesSection"><h2 class="lh-head">OpenBridge Systems <span id="openbridgesToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="openbridgesBody" class="sec-body"><div id="openbridges"></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:{}, hang:{}, log:[], hblink:false, ping_time:5, max_missed:3, 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 SERVER_REPEATERS_CFG = '{{SERVER_REPEATERS}}'; // 'open' | 'closed' — default state for server-system repeater lists
// ---------- helpers ----------
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 skey = e => e.system+'|'+e.slot+'|'+e.stream_id;
function fmtSlots(s){ return s==='3'||s===3?'Both':s==='1'||s===1?'TS1':s==='2'||s===2?'TS2':esc(String(s||'')); }
const idName = (name,id)=> name ? esc(name)+' <span class="sub mono">('+id+')</span>' : '<span class="mono">'+id+'</span>';
const callShort = ct => ct==='GROUP VOICE'?'Group Voice':ct==='UNIT VOICE'?'Unit Voice':ct==='GROUP DATA'?'Group Data':ct==='UNIT DATA'?'Unit Data':esc(ct);
// active call(s) for a system, by slot (most recent wins)
function activeBySlot(sys){ const r={1:null,2:null}; for(const k in ST.active){const e=ST.active[k]; if(e.system===sys) r[e.slot]=e;} return r; }
function activeList(sys){ const a=[]; for(const k in ST.active){const e=ST.active[k]; if(e.system===sys) a.push(e);} return a; }
function callCell(e, klass, hang){
if(!e && hang){
const tg = hang.dst_alias ? esc(hang.dst_alias) : esc(String(hang.dst||''));
const left = Math.max(0, hang._expires - Date.now()/1000);
return '<div class="call hang">'
+ '<div class="call-top">'+esc(hang.trx)+' hang → '+tg+'</div>'
+ '<div class="sub mono"><span class="js-countdown" data-rem="'+left.toFixed(1)+'" data-arr="'+Date.now()+'">'+dur(left)+'</span> remaining</div>'
+ '</div>';
}
if(!e) return '<div class="call idle"><div class="call-top">— idle —</div><div class="sub mono">&nbsp;</div></div>';
const src = e.src_alias ? esc(e.src_alias) : e.src;
const dst = e.dst_alias ? esc(e.dst_alias) : e.dst;
const d='<span class="js-dur" data-start="'+e._arrived+'">'+dur((Date.now()-e._arrived)/1000)+'</span>';
return '<div class="call '+klass+'">'
+ '<div class="call-top">'+src+' → '+dst+'</div>'
+ '<div class="sub mono">'+esc(e.trx)+' · '+callShort(e.call_type)+' · '+e.src+' · '+d+'</div>'
+ '</div>';
}
// ---------- renderers ----------
// ---- per-server-system repeater-list collapse (persisted per browser; SERVER_REPEATERS sets the default) ----
function srvCollapsedStore(){ try{ return JSON.parse(localStorage.getItem('hbl_srv_collapsed')||'{}'); }catch(e){ return {}; } }
function srvIsCollapsed(n){
const st = srvCollapsedStore();
return Object.prototype.hasOwnProperty.call(st, n) ? !!st[n] : (SERVER_REPEATERS_CFG==='closed');
}
function srvSetCollapsed(n, val){
const st = srvCollapsedStore(); st[n] = val;
localStorage.setItem('hbl_srv_collapsed', JSON.stringify(st));
}
function initServersCollapse(){
// Delegated so it survives renderServers() rebuilding #servers on every event.
el('servers').addEventListener('click', ev=>{
const btn = ev.target.closest('.srv-toggle'); if(!btn) return;
const block = btn.closest('.sys-block'); if(!block) return;
const n = block.dataset.sys;
srvSetCollapsed(n, !srvIsCollapsed(n));
renderServers();
});
}
function renderServers(){
const names = Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='SERVER');
if(!names.length){ el('servers').innerHTML='<div class="empty">No server systems</div>'; return; }
let h='';
for(const n of names){
const m=ST.systems[n], repeaters=m.REPEATERS||{}, act=activeBySlot(n);
const pids=Object.keys(repeaters);
// System-level slot pill, shown only when the repeater table is collapsed.
// Coloured by the call's own direction (system view has no per-repeater source).
const sysPill=(slot)=>{ const e=act[slot], hang=ST.hang[n+'|'+slot]||null;
return callCell(e||null, e?(e.trx==='TX'?'tx':'rx'):null, hang); };
const collapsed=srvIsCollapsed(n);
h+='<div class="sys-block'+(collapsed?' collapsed':'')+'" data-sys="'+esc(n)+'">';
h+='<div class="sys-row"><h3><span class="sysname">'+esc(n)+'</span> <span class="pill">'+(m.REPEAT?'repeat':'isolate')+'</span> <span class="sub">'+pids.length+' repeater(s)</span> '
+'<span class="toggle-btn srv-toggle" title="Show or hide this system’s connections"><span class="caret">'+(collapsed?'▸':'▾')+'</span>'+(collapsed?'Show Connections':'Hide Connections')+'</span></h3>'
+'<div class="ts-hdrs"><div class="ts-hdr">TS1</div><div class="ts-hdr">TS2</div></div></div>'
+'<div class="sys-pills"><div class="slotcol">'+sysPill(1)+'</div><div class="slotcol">'+sysPill(2)+'</div></div>';
h+='<table><tr><th>Repeater</th><th>Connection</th><th>Freq</th><th>Slots</th><th class="slotcol"></th><th class="slotcol"></th></tr>';
if(!pids.length) h+='<tr><td colspan="6" class="sub">No repeaters connected</td></tr>';
for(const pid of pids){
const p=repeaters[pid];
const cell=(slot)=>{ const e=act[slot], hang=ST.hang[n+'|'+slot]||null;
if(!e) return callCell(null, null, hang);
// Colour purely by direction: the call's own RX/TX for the source repeater,
// TX (red) for the others, which are hearing the repeated traffic.
return callCell(e, String(e.peer)===String(p.RADIO_ID) ? (e.trx==='TX'?'tx':'rx') : 'tx', hang); };
const freq = (p.RX_FREQ&&p.RX_FREQ.slice(0,3)!=='000') ? 'RX '+fmtFreq(p.RX_FREQ)+'<br>TX '+fmtFreq(p.TX_FREQ) : '<span class="sub">N/A</span>';
// Chronic connectivity: PING_LOSS is a slow, decaying ping-loss % computed
// server-side. A repeater that stays connected but loses pings over time
// (choppy/lossy link) shows a rising loss %; a clean one sits at 0.
const loss = p.PING_LOSS || 0;
const csStyle = loss >= ST.ping_loss_warn ? ' style="color:var(--warn)"' : '';
const lossTag = loss > 0 ? ' <span class="sub">· '+loss+'% ping loss</span>' : '';
h+='<tr><td class="sysname"'+csStyle+'>'+idName(p.CALLSIGN,p.RADIO_ID)+'<div class="sub">'+esc(p.LOCATION||'')+lossTag+'</div></td>'+
'<td class="mono">'+esc(p.IP)+':'+esc(p.PORT)+'<div class="sub">up <span class="js-since" data-base="'+(p.connected_secs||0)+'" data-arr="'+Date.now()+'">'+dur(p.connected_secs||0)+'</span></div></td>'+
'<td class="mono sub">'+freq+'</td><td class="mono">'+fmtSlots(p.SLOTS)+'</td>'+
'<td>'+cell(1)+'</td><td>'+cell(2)+'</td></tr>';
}
h+='</table>';
h+='</div>'; // close .sys-block (collapsible repeater list)
}
el('servers').innerHTML=h;
}
function fmtFreq(f){ return f && f.length>=7 ? (f.slice(0,3)+'.'+f.slice(3,7)) : esc(f); }
function renderOutbounds(){
const names=Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='OUTBOUND');
if(!names.length){ el('outbounds').innerHTML='<div class="empty">No outbound systems</div>'; return; }
let h='';
for(const n of names){
const s=ST.systems[n], st=s.STATS||{}, act=activeBySlot(n);
const up = st.CONNECTION==='YES';
const warn = (st.NUM_OUTSTANDING||0) > 0;
const nameStyle = warn ? ' style="color:var(--warn)"' : '';
const since = st.connected_secs!=null ? '<span class="js-since" data-base="'+st.connected_secs+'" data-arr="'+Date.now()+'">'+dur(st.connected_secs)+'</span>' : '—';
// Single-entry system: fold the name into the header row (col 1) and the
// TS1/TS2 labels into the slot-column headers, dropping the separate sys-row.
h+='<table><tr><th class="sysname"'+nameStyle+'>'+esc(n)+'</th><th>Status</th><th>Slots</th><th class="slotcol">TS1</th><th class="slotcol">TS2</th></tr>';
h+='<tr><td>'+idName(s.CALLSIGN,s.RADIO_ID)+'<div class="sub">'+esc(s.LOCATION||'')+' · server '+esc(s.SERVER_IP)+':'+esc(s.SERVER_PORT)+'</div></td>'+
'<td><span class="dot '+(up?'ok':'bad')+'"></span><span class="'+(up?'status-ok':'status-bad')+'">'+(up?'Connected':'Disconnected')+'</span><div class="sub">up '+since+'</div></td>'+
'<td class="mono">'+fmtSlots(s.SLOTS)+'</td>'+
'<td>'+callCell(act[1], act[1]&&act[1].trx==='TX'?'tx':'rx', ST.hang[n+'|1']||null)+'</td>'+
'<td>'+callCell(act[2], act[2]&&act[2].trx==='TX'?'tx':'rx', ST.hang[n+'|2']||null)+'</td></tr>';
h+='</table>';
}
el('outbounds').innerHTML=h;
}
function renderOpenbridges(){
const names=Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='OPENBRIDGE');
if(!names.length){ el('openbridges').innerHTML='<div class="empty">No OpenBridge systems</div>'; return; }
let h='<table><tr><th>System</th><th>Network / Target</th><th>Active Streams</th></tr>';
for(const n of names){
const o=ST.systems[n], streams=activeList(n);
const cells = streams.length ? streams.map(e=>'<span class="call '+(e.trx==='TX'?'tx':'rx')+'" style="display:inline-block;width:auto;margin:2px">'+e.trx+' · '+idName(e.src_alias,e.src)+' → '+idName(e.dst_alias,e.dst)+'</span>').join(' ') : '<span class="call idle" style="display:inline-block;width:auto">— idle —</span>';
h+='<tr><td class="sysname">'+esc(n)+'</td><td class="mono">ID '+esc(o.NETWORK_ID)+'<div class="sub">'+esc(o.TARGET_IP)+':'+esc(o.TARGET_PORT)+'</div></td><td>'+cells+'</td></tr>';
}
el('openbridges').innerHTML=h+'</table>';
}
function renderBridges(){
const names=Object.keys(ST.bridges);
if(!names.length){ el('bridges').innerHTML='<div class="empty">No conference bridges (or running bridge_all / hblink standalone)</div>'; return; }
// One shared table for all bridges so every column lines up across them
// (each bridge gets a full-width sub-header row). Per-bridge tables auto-size
// their columns independently, which misaligns Action width and wide TGIDs.
let h='<table class="bridges"><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.remaining==null ? 0 : m.remaining) : 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">'+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||Date.now()).toLocaleTimeString();
const cls=e.action==='START'?'start':'end';
const d=e.duration!=null?(' · '+e.duration.toFixed(1)+'s'):'';
return '<div><span class="ts">'+t+'</span> <span class="'+cls+'">'+callShort(e.call_type)+' '+e.action+'</span> · '+
esc(e.system)+' · '+idName(e.src_alias,e.src)+' TS'+e.slot+' → '+idName(e.dst_alias,e.dst)+d+'</div>';
}
function renderLog(){ el('log').innerHTML = ST.log.map(logRow).join(''); }
// ---------- last heard ----------
// Most-recent unique callers, derived from the RX (ingress) call log — no server
// state of its own. Deduped by source radio ID, newest first.
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){
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 src = e.peer!=null ? idName(e.peer_alias, e.peer) : esc(e.system||'');
const t = e._ts ? e._ts*1000 : (e._arrived||Date.now());
const when = lhActiveSrc(e.src)
? '<span class="status-ok">Active</span>'
: '<span class="js-lh mono" data-t="'+t+'">'+dur((Date.now()-t)/1000)+' ago</span>';
h += '<tr><td>'+idName(e.src_alias,e.src)+'</td>'
+ '<td class="sub">'+callShort(e.call_type)+'</td>'
+ '<td class="mono sub">'+src+'</td>'
+ '<td class="mono">TS'+esc(e.slot||'?')+'</td>'
+ '<td>'+idName(e.dst_alias,e.dst)+'</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);
});
}
// ---------- live tick ----------
setInterval(()=>{
const now=Date.now();
// reap stale active streams whose END never arrived (interrupted/lost calls)
let reaped=false;
for(const k in ST.active){ if(now-ST.active[k]._arrived>STALE){ delete ST.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) renderAll();
document.querySelectorAll('.js-since').forEach(s=>{ s.textContent=dur((+s.dataset.base)+(now-(+s.dataset.arr))/1000); });
document.querySelectorAll('.js-dur').forEach(s=>{ s.textContent=dur((now-(+s.dataset.start))/1000); });
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-lh').forEach(s=>{ s.textContent=dur((now-(+s.dataset.t))/1000)+' ago'; });
},1000);
// ---------- websocket ----------
function setConn(up){ ST.hblink=up; const c=el('conn'); c.className='badge '+(up?'up':'down'); c.textContent='HBlink: '+(up?'connected':'disconnected'); }
function renderAll(){ renderServers(); renderOutbounds(); renderOpenbridges(); renderBridges(); renderLog(); renderLastHeard(); }
function applyStream(e){
e._arrived = e._arrived || Date.now();
const hkey = e.system+'|'+e.slot;
if(e.action==='START'){
delete ST.hang[hkey];
ST.active[skey(e)]=e;
} else {
delete ST.active[skey(e)];
const ht = (ST.systems[e.system]||{}).GROUP_HANGTIME||0;
if(ht>0) ST.hang[hkey] = Object.assign({}, e, {_expires: Date.now()/1000 + ht});
}
if(e.trx==='RX'){ ST.log.unshift(e); if(ST.log.length>400) ST.log.pop(); renderLog(); renderLastHeard(); }
// re-render the affected system class
const sys=ST.systems[e.system];
if(sys&&sys.MODE==='SERVER') renderServers();
else if(sys&&sys.MODE==='OUTBOUND') renderOutbounds();
else if(sys&&sys.MODE==='OPENBRIDGE') renderOpenbridges();
else renderServers();
}
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.hblink); ST.systems=m.systems||{}; ST.bridges=m.bridges||{};
if(m.ping_time) ST.ping_time=m.ping_time; if(m.max_missed) ST.max_missed=m.max_missed; if(m.ping_loss_warn!=null) ST.ping_loss_warn=m.ping_loss_warn;
ST.active={}; (m.streams||[]).forEach(s=>{s._arrived=Date.now(); ST.active[skey(s)]=s;});
ST.log=(m.log||[]).map(e=>{e._arrived=e._arrived||Date.now();return e;}); renderAll(); }
else if(m.type==='hblink'){ setConn(m.connected); if(!m.connected){ST.systems={};ST.bridges={};ST.active={};renderAll();} }
else if(m.type==='config'){ ST.systems=m.systems||{};
if(m.ping_time) ST.ping_time=m.ping_time; if(m.max_missed) ST.max_missed=m.max_missed; if(m.ping_loss_warn!=null) ST.ping_loss_warn=m.ping_loss_warn;
renderServers(); renderOutbounds(); renderOpenbridges(); }
else if(m.type==='bridges'){ ST.bridges=m.bridges||{}; renderBridges(); }
else if(m.type==='stream_start'||m.type==='stream_end'){ applyStream(m); }
};
sock.onclose=()=>{ setConn(false); el('conn').textContent='HBlink: dashboard offline'; setTimeout(connect,2000); };
}
// ---------------------------------------------------------------------------
// Demo mode (?demo in URL) — coherent static scenario, no WebSocket.
// Traffic paradigm:
// US-NATIONWIDE (TGID 31, TS1): W0ABC keyed up on KS-SERVER →
// KS-SERVER TS1 RX, KC-OUTBOUND TS1 TX, BRANDMEISTER TS1 TX
// KS-STATEWIDE (TGID 9, TS2): N0CAL transmitting via BrandMeister →
// BRANDMEISTER TS2 RX, KS-SERVER TS2 TX to all repeaters
// MO-LOCAL (TGID 3100, TS2): W0DEF just finished →
// KC-OUTBOUND TS2 in TX hang
// ---------------------------------------------------------------------------
if(new URLSearchParams(location.search).has('demo')){
const _t = Date.now(); // ms (for _arrived, durations)
const _ts = _t / 1000; // seconds (for hang _expires)
ST.systems = {
'KS-SERVER': {
MODE:'SERVER', GROUP_HANGTIME:5, REPEAT:true,
REPEATERS:{
'3112001':{ RADIO_ID:'3112001', CALLSIGN:'W0ABC', LOCATION:'Topeka, KS', RX_FREQ:'448800000', TX_FREQ:'443800000', SLOTS:'3', IP:'10.1.1.1', PORT:50000, connected_secs:3672, last_ping_secs:2.1, PING_LOSS:0 },
'3112002':{ RADIO_ID:'3112002', CALLSIGN:'KD0XYZ', LOCATION:'Lawrence, KS', RX_FREQ:'446525000', TX_FREQ:'441525000', SLOTS:'3', IP:'10.1.1.2', PORT:50000, connected_secs:1245, last_ping_secs:3.8, PING_LOSS:3 },
'3112003':{ RADIO_ID:'3112003', CALLSIGN:'N0MJS', LOCATION:'Wichita, KS', RX_FREQ:'449500000', TX_FREQ:'444500000', SLOTS:'3', IP:'10.1.1.3', PORT:50000, connected_secs:412, last_ping_secs:8.3, PING_LOSS:24 },
},
},
'KC-OUTBOUND': {
MODE:'OUTBOUND', GROUP_HANGTIME:5,
CALLSIGN:'K0ABD', RADIO_ID:'3129200', LOCATION:'Kansas City, MO',
SERVER_IP:'10.5.0.1', SERVER_PORT:54001, SLOTS:'3',
STATS:{ CONNECTION:'YES', NUM_OUTSTANDING:0, connected_secs:5412 },
},
'BRANDMEISTER': {
MODE:'OPENBRIDGE',
NETWORK_ID:'3129100', TARGET_IP:'bm.brandmeister.network', TARGET_PORT:62031,
},
};
ST.bridges = {
'US-NATIONWIDE':[
{ SYSTEM:'KS-SERVER', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'KC-OUTBOUND', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'BRANDMEISTER', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
],
'KS-STATEWIDE':[
{ SYSTEM:'KS-SERVER', TS:2, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'BRANDMEISTER', TS:2, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
],
'MO-LOCAL':[
{ SYSTEM:'KS-SERVER', TS:2, TGID:3100, TGID_NAME:'MO Local', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'KC-OUTBOUND', TS:2, TGID:3100, TGID_NAME:'MO Local', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
],
};
// Active streams — keyed by system|slot|stream_id
// W0ABC on US Nationwide TS1 (started 5s ago); N0CAL on KS Statewide TS2 (started 3s ago).
// _arrived in ms; keepalive window is 30s so setting recently to prevent demo-mode reaping.
ST.active = {
'KS-SERVER|1|aabb0011': { system:'KS-SERVER', slot:1, stream_id:'aabb0011', trx:'RX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
'KS-SERVER|2|ccdd0022': { system:'KS-SERVER', slot:2, stream_id:'ccdd0022', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide', peer:9990001, _arrived:_t-3000 },
'KC-OUTBOUND|1|aabb0011': { system:'KC-OUTBOUND', slot:1, stream_id:'aabb0011', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
'BRANDMEISTER|1|aabb0011': { system:'BRANDMEISTER', slot:1, stream_id:'aabb0011', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
'BRANDMEISTER|2|ccdd0022': { system:'BRANDMEISTER', slot:2, stream_id:'ccdd0022', trx:'RX', action:'START', call_type:'GROUP VOICE', src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide', peer:3188765, _arrived:_t-3000 },
};
// KC-OUTBOUND TS2 in TX hang — W0DEF's MO Local call ended 4s ago, 5s hangtime
ST.hang = {
'KC-OUTBOUND|2':{ trx:'TX', dst:3100, dst_alias:'MO Local', _expires:_ts+26,
system:'KC-OUTBOUND', slot:2, call_type:'GROUP VOICE', src:3161001, _arrived:_t-4000 },
};
// Call log, most-recent first; _arrived in ms
ST.log = [
{ _arrived:_t-3000, action:'START', call_type:'GROUP VOICE', system:'BRANDMEISTER', slot:2, src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide' },
{ _arrived:_t-5000, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide' },
{ _arrived:_t-4000, action:'END', call_type:'GROUP VOICE', system:'KC-OUTBOUND', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local', duration:14.2 },
{ _arrived:_t-4000, action:'END', call_type:'GROUP VOICE', system:'KS-SERVER', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local', duration:14.2 },
{ _arrived:_t-18200, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local' },
{ _arrived:_t-129000, action:'END', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112002, src_alias:'KD0XYZ', dst:31, dst_alias:'US Nationwide', duration:8.7 },
{ _arrived:_t-137700, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112002, src_alias:'KD0XYZ', dst:31, dst_alias:'US Nationwide' },
];
setConn(true);
renderAll();
// No WebSocket in demo mode — setInterval ticker still runs for live timers.
} else {
connect();
}
initLastHeard();
initServersCollapse();
initCollapse('servers','serversBody','serversToggle', false);
initCollapse('outbounds','outboundsBody','outboundsToggle', false);
initCollapse('openbridges','openbridgesBody','openbridgesToggle', false);
initCollapse('bridges','bridgesBody','bridgesToggle', false);
initCollapse('log','logBody','logToggle', false);
</script>
</body>
</html>