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:
1 parent
48c35c20bd
commit
2836f71ec7
9 files changed
+605
No files matched your search
@@ -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__/
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user