Add HBlink3 dashboard (FastAPI + Uvicorn, modern UI)

A real-time web dashboard for HBlink3 -- a separate program that replaces the old
HBmonitor. It keeps HBmonitor's information and table layout (master systems with
their repeaters, peer systems, OpenBridge systems, conference-bridge state, and a
call log) but borrows HBlink4's modern dark aesthetic and an efficient,
event-driven data path.

How it works:
- dashboard/server.py connects to HBlink3's NDJSON reporting feed, maintains the
  authoritative display state, resolves DMR IDs to callsigns/talkgroup names via
  the shared alias files, and serves a single-page UI plus a WebSocket that pushes
  JSON deltas (and a small REST API at /api/state).
- dashboard/static/dashboard.html renders the tables client-side and ticks the
  uptime / call-duration / bridge-timeout counters in the browser, so there are no
  full-table re-renders and the duration figures update live. Live calls are
  pushed instantly; systems/peer state refreshes on the periodic config snapshot
  (REPORT_INTERVAL).

Efficiency vs HBmonitor: JSON deltas instead of whole-table HTML pushed every
tick; client-side rendering and live counters; no pickle on the wire; server-
computed time deltas so the browser needs no clock sync.

Includes config_sample.py, requirements.txt (FastAPI/Uvicorn/dmr_utils3 -- the
core stays stdlib-only), README, run_dashboard.py launcher, and an
hblink3-dash.service systemd unit. dashboard/config.py is git-ignored.

Verified end-to-end against a live bridge.py: the dashboard ingests the config
and bridge snapshots, resolves a talkgroup alias, and serves the branded SPA;
frontend JS parses clean; the HBlink3 core test suite is unaffected (39 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cort BuffingtonandClaude Opus 4.8 committed 2026-06-24 13:54:28 -05:00
1 parent 48c35c20bd
commit 2836f71ec7
9 files changed
+605

No files matched your search

+4
View File
@@ -98,3 +98,7 @@ local_subscriber_ids.*
peer_ids.*
local_peer_ids.*
talkgroup_ids.*
# Dashboard local config (copy from dashboard/config_sample.py)
dashboard/config.py
dashboard/__pycache__/
+7
View File
@@ -43,6 +43,13 @@ See **[INSTALL.md](INSTALL.md)** for full setup, configuration, and running as a
Your `hblink.cfg` and `rules.py` are git-ignored, so `git pull` won't overwrite them.
## Dashboard
A real-time web dashboard lives in [`dashboard/`](dashboard/) — a separate program
with a modern dark UI showing master/peer/OpenBridge systems, conference-bridge
state, and a live call log. Enable HBlink3's reporting feed (`[REPORTS]` in
`hblink.cfg`) and see [dashboard/README.md](dashboard/README.md) to run it.
## Requirements
- Python **3.8+** (Linux recommended)
+61
View File
@@ -0,0 +1,61 @@
# HBlink3 Dashboard
A real-time web dashboard for HBlink3, in the spirit of the old HBmonitor (same
tables and information) but with a modern dark UI and a far more efficient data
path. It is a **separate program** from the HBlink3 core.
It connects to HBlink3's reporting feed, keeps the display state, and pushes
live **JSON deltas** to the browser over a WebSocket. The browser renders the
tables and ticks the duration/uptime counters itself — no full-page or
full-table refreshes, and no pickle on the wire.
## What it shows
- **Master Systems** — each master and its connected repeaters (ID, callsign,
location, IP, uptime, frequency, colorcode, slots) with the live call on each
timeslot (source → destination, color-coded by direction).
- **Peer Systems** — outbound peer connections with health (connection state,
pings sent/ackd/lost) and live per-timeslot calls.
- **OpenBridge Systems** — network ID, target, and active streams.
- **Conference Bridges** — each bridge's members: timeslot, talkgroup,
connected/disconnected state, timeout countdown, and on/off trigger talkgroups.
- **Call Log** — a running log of call starts and ends.
## Install
```bash
cd dashboard
python3 -m venv venv && source venv/bin/activate # or reuse the HBlink3 venv
pip install -r requirements.txt
cp config_sample.py config.py # then edit config.py
python server.py # or, from the repo root: python run_dashboard.py
```
Open `http://<host>:8080`.
## Configuration (`config.py`)
- `HBLINK_IP` / `HBLINK_PORT` — HBlink3's reporting feed (the `[REPORTS]` section
of `hblink.cfg`: `REPORT_PORT`, default 4321).
- `WEB_HOST` / `WEB_PORT` — where the dashboard listens.
- `PATH` / `*_FILE` — alias files mapping DMR IDs to callsigns/talkgroup names
(the same files HBlink3 uses; point `PATH` at HBlink3's directory to share them).
- `REPORT_NAME`, `LOG_LINES` — branding and call-log length.
## HBlink3 side
In `hblink.cfg`, under `[REPORTS]`:
- `REPORT: True`
- `REPORT_PORT` must match the dashboard's `HBLINK_PORT`
- `REPORT_CLIENTS` must include the dashboard host's IP (or `*`)
- `REPORT_INTERVAL` sets how often the systems/bridge snapshot is refreshed.
**Live calls are pushed instantly**; connection/peer-state changes appear on the
next snapshot, so `10` is a good value for dashboard use.
## Notes
- This replaces the old **HBmonitor**. HBlink3's reporting feed is now
newline-delimited JSON, which HBmonitor does not understand.
- The dashboard depends on FastAPI + Uvicorn; the HBlink3 core itself remains
stdlib-only.
+33
View File
@@ -0,0 +1,33 @@
###############################################################################
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
###############################################################################
# Copy this file to config.py and edit for your install.
# Display / branding
REPORT_NAME = 'My HBlink3 System' # Shown in the dashboard header
# Connection to HBlink3's reporting feed (the [REPORTS] section of hblink.cfg)
HBLINK_IP = '127.0.0.1' # HBlink3 reporting host
HBLINK_PORT = 4321 # HBlink3 reporting TCP port (REPORT_PORT)
# Web server
WEB_HOST = '0.0.0.0' # Interface to bind the dashboard web server
WEB_PORT = 8080 # Port (must be > 1024 if not running as root)
# Call log
LOG_LINES = 300 # Number of recent call-log entries to retain
# Alias files (map DMR IDs to callsigns/talkgroup names). Same files HBlink3 uses;
# point PATH at HBlink3's directory to share them, or keep your own copies.
PATH = '../' # MUST END IN '/'
PEER_FILE = 'peer_ids.json'
SUBSCRIBER_FILE = 'subscriber_ids.json'
TGID_FILE = 'talkgroup_ids.json' # optional, {id: name}; ok if missing
LOCAL_SUB_FILE = '' # optional local override, '' to disable
LOCAL_PEER_FILE = '' # optional local override, '' to disable
+5
View File
@@ -0,0 +1,5 @@
# HBlink3 dashboard dependencies (a separate program from the core).
# The dashboard uses FastAPI + Uvicorn; the HBlink3 core itself stays stdlib-only.
fastapi>=0.110
uvicorn[standard]>=0.27
dmr_utils3>=0.1.30 # DMR ID -> callsign / talkgroup-name resolution
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python
#
###############################################################################
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
###############################################################################
'''
HBlink3 dashboard backend.
Connects to HBlink3's NDJSON reporting feed (one JSON object per line), keeps the
authoritative display state, and serves a single-page UI that receives live JSON
deltas over a WebSocket. Run: python server.py (or via run_dashboard.py).
'''
import asyncio
import json
import logging
import os
import sys
import time
from collections import deque
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
import uvicorn
from dmr_utils3.utils import mk_id_dict, get_alias
# Ensure THIS directory's config.py wins over HBlink3's top-level config.py.
HERE = os.path.dirname(os.path.abspath(__file__))
STATIC = os.path.join(HERE, 'static')
sys.path.insert(0, HERE)
# ---- configuration -----------------------------------------------------------
try:
from config import (REPORT_NAME, HBLINK_IP, HBLINK_PORT, WEB_HOST, WEB_PORT,
LOG_LINES, PATH, PEER_FILE, SUBSCRIBER_FILE, TGID_FILE,
LOCAL_SUB_FILE, LOCAL_PEER_FILE)
except ImportError:
sys.exit('No config.py found -- copy config_sample.py to config.py and edit it.')
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('hbdash')
# ---- alias resolution --------------------------------------------------------
def _abs(p):
return p if os.path.isabs(p) else os.path.join(HERE, p)
def load_aliases():
base = _abs(PATH)
peer_ids = mk_id_dict(base, PEER_FILE)
subscriber_ids = mk_id_dict(base, SUBSCRIBER_FILE)
talkgroup_ids = mk_id_dict(base, TGID_FILE)
if LOCAL_PEER_FILE:
peer_ids.update(mk_id_dict(base, LOCAL_PEER_FILE))
if LOCAL_SUB_FILE:
subscriber_ids.update(mk_id_dict(base, LOCAL_SUB_FILE))
logger.info('aliases loaded: %d peers, %d subscribers, %d talkgroups',
len(peer_ids), len(subscriber_ids), len(talkgroup_ids))
return peer_ids, subscriber_ids, talkgroup_ids
PEER_IDS, SUBSCRIBER_IDS, TALKGROUP_IDS = load_aliases()
def alias(_id, _dict):
a = get_alias(_id, _dict)
return None if a == _id else a
# ---- shared state ------------------------------------------------------------
class State:
def __init__(self):
self.systems = {} # last 'config' event payload
self.bridges = {} # last 'bridges' event payload (enriched)
self.streams = {} # key -> last START stream event (active calls)
self.log = deque(maxlen=LOG_LINES)
self.hblink = False
self.clients = set() # connected dashboard WebSockets
STATE = State()
def stream_key(evt):
return '{}|{}|{}'.format(evt['system'], evt['slot'], evt['stream_id'])
def enrich_stream(evt):
evt['src_alias'] = alias(evt['src'], SUBSCRIBER_IDS)
evt['peer_alias'] = alias(evt['peer'], PEER_IDS)
evt['dst_alias'] = alias(evt['dst'], TALKGROUP_IDS)
return evt
# Convert server-side absolute epochs to "seconds since/until" deltas so the
# browser can tick live counters without depending on the client's clock.
def enrich_config(systems):
now = time.time()
for sysview in systems.values():
if sysview['MODE'] == 'MASTER':
for p in sysview.get('PEERS', {}).values():
p['connected_secs'] = int(max(0, now - p.get('CONNECTED', now)))
elif sysview['MODE'] == 'PEER':
c = sysview.get('STATS', {}).get('CONNECTED')
sysview['STATS']['connected_secs'] = int(max(0, now - c)) if c else None
return systems
def enrich_bridges(bridges):
now = time.time()
for members in bridges.values():
for m in members:
m['TGID_NAME'] = alias(m['TGID'], TALKGROUP_IDS)
if m['TO_TYPE'] in ('ON', 'OFF'):
m['remaining'] = int(m['TIMER'] - now)
else:
m['remaining'] = None
return bridges
async def broadcast(obj):
dead = set()
for ws in STATE.clients:
try:
await ws.send_json(obj)
except Exception:
dead.add(ws)
STATE.clients -= dead
async def handle_event(evt):
t = evt.get('type')
if t == 'config':
STATE.systems = enrich_config(evt['systems'])
await broadcast({'type': 'config', 'systems': STATE.systems})
elif t == 'bridges':
STATE.bridges = enrich_bridges(evt['bridges'])
await broadcast({'type': 'bridges', 'bridges': STATE.bridges})
elif t == 'stream':
enrich_stream(evt)
key = stream_key(evt)
if evt['action'] == 'START':
STATE.streams[key] = evt
else:
STATE.streams.pop(key, None)
# Log ingress (RX) legs only, matching the original monitor's behavior.
if evt['trx'] == 'RX':
STATE.log.appendleft(evt)
await broadcast(evt)
else:
logger.debug('ignoring unknown event type: %s', t)
# ---- HBlink3 feed client (with reconnect) ------------------------------------
async def hblink_feed():
while True:
try:
reader, writer = await asyncio.open_connection(HBLINK_IP, HBLINK_PORT)
logger.info('connected to HBlink3 feed at %s:%s', HBLINK_IP, HBLINK_PORT)
STATE.hblink = True
await broadcast({'type': 'hblink', 'connected': True})
while True:
line = await reader.readline()
if not line:
break # connection closed
try:
await handle_event(json.loads(line))
except json.JSONDecodeError:
logger.warning('bad JSON line from HBlink3: %r', line[:120])
except (ConnectionRefusedError, OSError) as e:
logger.warning('HBlink3 feed unavailable (%s); retrying', e)
finally:
if STATE.hblink:
STATE.hblink = False
STATE.systems = {}
STATE.bridges = {}
STATE.streams = {}
await broadcast({'type': 'hblink', 'connected': False})
await asyncio.sleep(3) # reconnect delay
@asynccontextmanager
async def lifespan(app):
task = asyncio.create_task(hblink_feed())
yield
task.cancel()
app = FastAPI(lifespan=lifespan)
# ---- HTTP + WebSocket --------------------------------------------------------
@app.get('/', response_class=HTMLResponse)
async def index():
with open(os.path.join(STATIC, 'dashboard.html'), encoding='utf-8') as f:
html = f.read()
return html.replace('{{REPORT_NAME}}', REPORT_NAME)
@app.get('/api/state')
async def api_state():
return JSONResponse(current_state())
def current_state():
return {
'report_name': REPORT_NAME,
'hblink': STATE.hblink,
'systems': STATE.systems,
'bridges': STATE.bridges,
'streams': list(STATE.streams.values()),
'log': list(STATE.log),
}
@app.websocket('/ws')
async def ws_endpoint(ws: WebSocket):
await ws.accept()
STATE.clients.add(ws)
try:
await ws.send_json({'type': 'initial', **current_state()})
while True:
await ws.receive_text() # ignore; keep the socket open
except WebSocketDisconnect:
pass
finally:
STATE.clients.discard(ws)
def main():
uvicorn.run(app, host=WEB_HOST, port=WEB_PORT, log_level='warning')
if __name__ == '__main__':
main()
+217
View File
@@ -0,0 +1,217 @@
<!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;
}
*{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}
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)}
.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}
.call.idle{color:#475569;background:var(--panel2)}
.call.rx{background:var(--green-bg);color:#86efac;border:1px solid var(--green)}
.call.tx{background:var(--orange-bg);color:#fdba74;border:1px solid var(--orange)}
.call.hear{background:var(--red-bg);color:#fca5a5;border:1px solid var(--red)}
.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:230px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>{{REPORT_NAME}}<small>HBlink3 Monitor</small></h1>
<div id="conn" class="badge down">HBlink: connecting…</div>
</header>
<section><h2>Master Systems</h2><div id="masters"></div></section>
<section><h2>Peer Systems</h2><div id="peers"></div></section>
<section><h2>OpenBridge Systems</h2><div id="openbridges"></div></section>
<section><h2>Conference Bridges</h2><div id="bridges"></div></section>
<section><h2>Call Log</h2><div id="log"></div></section>
</div>
<script>
const ST = { systems:{}, bridges:{}, active:{}, log:[], hblink:false };
// ---------- 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;
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':ct==='UNIT VOICE'?'Unit':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){
if(!e) return '<div class="call idle">— idle —</div>';
const d='<span class="js-dur" data-start="'+e._arrived+'">'+dur((Date.now()-e._arrived)/1000)+'</span>';
return '<div class="call '+klass+'">'+callShort(e.call_type)+' · '+idName(e.src_alias,e.src)+
' → '+idName(e.dst_alias,e.dst)+' <span class="sub">['+d+']</span></div>';
}
// ---------- renderers ----------
function renderMasters(){
const names = Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='MASTER');
if(!names.length){ el('masters').innerHTML='<div class="empty">No master systems</div>'; return; }
let h='';
for(const n of names){
const m=ST.systems[n], peers=m.PEERS||{}, act=activeBySlot(n);
const pids=Object.keys(peers);
h+='<h3><span class="sysname">'+esc(n)+'</span> <span class="pill">'+(m.REPEAT?'repeat':'isolate')+'</span> <span class="sub">'+pids.length+' peer(s)</span></h3>';
h+='<table><tr><th>Repeater</th><th>Connection</th><th>Freq</th><th>CC / Slots</th><th class="slotcol">TS1</th><th class="slotcol">TS2</th></tr>';
if(!pids.length) h+='<tr><td colspan="6" class="sub">No peers connected</td></tr>';
for(const pid of pids){
const p=peers[pid];
const cell=(slot)=>{ const e=act[slot]; if(!e) return callCell(null);
return callCell(e, String(e.peer)===String(p.RADIO_ID) ? (e.trx==='TX'?'tx':'rx') : 'hear'); };
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>';
h+='<tr><td class="sysname">'+idName(p.CALLSIGN,p.RADIO_ID)+'<div class="sub">'+esc(p.LOCATION||'')+'</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">CC '+esc(p.COLORCODE)+'<div class="sub">slots '+esc(p.SLOTS)+'</div></td>'+
'<td>'+cell(1)+'</td><td>'+cell(2)+'</td></tr>';
}
h+='</table>';
}
el('masters').innerHTML=h;
}
function fmtFreq(f){ return f && f.length>=7 ? (f.slice(0,3)+'.'+f.slice(3,7)) : esc(f); }
function renderPeers(){
const names=Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='PEER');
if(!names.length){ el('peers').innerHTML='<div class="empty">No peer systems</div>'; return; }
let h='<table><tr><th>System</th><th>Identity</th><th>Connection</th><th>Pings S/A/L</th><th class="slotcol">TS1</th><th class="slotcol">TS2</th></tr>';
for(const n of names){
const s=ST.systems[n], st=s.STATS||{}, act=activeBySlot(n);
const up = st.CONNECTION==='YES';
const pings = (st.PINGS_SENT||0)+' / '+(st.PINGS_ACKD||0)+' / '+((st.PINGS_SENT||0)-(st.PINGS_ACKD||0));
const since = st.connected_secs!=null ? '<span class="js-since" data-base="'+st.connected_secs+'" data-arr="'+Date.now()+'">'+dur(st.connected_secs)+'</span>' : '—';
h+='<tr><td class="sysname">'+esc(n)+'<div class="sub">'+esc(s.MODE)+'</div></td>'+
'<td>'+idName(s.CALLSIGN,s.RADIO_ID)+'<div class="sub">'+esc(s.LOCATION||'')+' · master '+esc(s.MASTER_IP)+':'+esc(s.MASTER_PORT)+'</div></td>'+
'<td><span class="dot '+(up?'ok':'bad')+'"></span><span class="'+(up?'status-ok':'status-bad')+'">'+esc(st.CONNECTION||'NO')+'</span><div class="sub">up '+since+'</div></td>'+
'<td class="mono sub">'+pings+'</td><td>'+callCell(act[1], act[1]&&act[1].trx==='TX'?'tx':'rx')+'</td><td>'+callCell(act[2], act[2]&&act[2].trx==='TX'?'tx':'rx')+'</td></tr>';
}
el('peers').innerHTML=h+'</table>';
}
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;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">— 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; }
let h='';
for(const b of names){
h+='<h3>'+esc(b)+'</h3><table><tr><th>System</th><th>TS</th><th>TGID</th><th>Status</th><th>Timeout</th><th>Action</th><th>Connect TGIDs</th><th>Disconnect TGIDs</th></tr>';
for(const m of ST.bridges[b]){
const on=m.ACTIVE;
const to = m.TO_TYPE==='ON'||m.TO_TYPE==='OFF';
const rem = to ? '<span class="js-countdown mono" data-rem="'+(m.remaining==null?0:m.remaining)+'" data-arr="'+Date.now()+'">'+(m.remaining<=0?'expired':dur(m.remaining))+'</span>' : '<span class="sub">N/A</span>';
const act = m.TO_TYPE==='ON'?'Disconnect':m.TO_TYPE==='OFF'?'Connect':'<span class="sub">None</span>';
h+='<tr><td class="sysname">'+esc(m.SYSTEM)+'</td><td class="mono">'+m.TS+'</td><td>'+idName(m.TGID_NAME,m.TGID)+'</td>'+
'<td><span class="'+(on?'status-ok':'status-bad')+'">'+(on?'Connected':'Disconnected')+'</span></td>'+
'<td>'+rem+'</td><td>'+act+'</td><td class="mono sub">'+(m.ON||[]).join(', ')+'</td><td class="mono sub">'+(m.OFF||[]).join(', ')+'</td></tr>';
}
h+='</table>';
}
el('bridges').innerHTML=h;
}
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(''); }
// ---------- live tick ----------
setInterval(()=>{
const now=Date.now();
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); });
},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(){ renderMasters(); renderPeers(); renderOpenbridges(); renderBridges(); renderLog(); }
function applyStream(e){
e._arrived = e._arrived || Date.now();
if(e.action==='START') ST.active[skey(e)]=e; else delete ST.active[skey(e)];
if(e.trx==='RX'){ ST.log.unshift(e); if(ST.log.length>400) ST.log.pop(); renderLog(); }
// re-render the affected system class
const sys=ST.systems[e.system];
if(sys&&sys.MODE==='MASTER') renderMasters();
else if(sys&&sys.MODE==='PEER') renderPeers();
else if(sys&&sys.MODE==='OPENBRIDGE') renderOpenbridges();
else renderMasters();
}
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||{};
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||{}; renderMasters(); renderPeers(); renderOpenbridges(); }
else if(m.type==='bridges'){ ST.bridges=m.bridges||{}; renderBridges(); }
else if(m.type==='stream'){ applyStream(m); }
};
sock.onclose=()=>{ setConn(false); el('conn').textContent='HBlink: dashboard offline'; setTimeout(connect,2000); };
}
connect();
</script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
# systemd unit for the HBlink3 dashboard (dashboard/server.py).
#
# Install:
# sudo cp hblink3-dash.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now hblink3-dash
#
# Adjust User/Group and the paths below to your install. The dashboard reads
# dashboard/config.py for its settings (copy it from dashboard/config_sample.py).
[Unit]
Description=HBlink3 Dashboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cort
Group=cort
WorkingDirectory=/home/cort/hblink3/dashboard
ExecStart=/home/cort/hblink3/venv/bin/python /home/cort/hblink3/dashboard/server.py
Restart=always
RestartSec=10
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
# Logging to the journal (journalctl -u hblink3-dash)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hblink3-dash
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python
# Convenience launcher for the HBlink3 dashboard. Equivalent to running
# `python dashboard/server.py`.
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard'))
import server
if __name__ == '__main__':
server.main()