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>
487 lines
32 KiB
HTML
487 lines
32 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}} — 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=>({'&':'&','<':'<','>':'>','"':'"'}[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"> </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>
|