From 2836f71ec7ea741c03106f2ae01ce900cf396ca4 Mon Sep 17 00:00:00 2001 From: Cort Buffington Date: Wed, 24 Jun 2026 13:54:28 -0500 Subject: [PATCH] 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 --- .gitignore | 4 + README.md | 7 + dashboard/README.md | 61 +++++++++ dashboard/config_sample.py | 33 +++++ dashboard/requirements.txt | 5 + dashboard/server.py | 232 ++++++++++++++++++++++++++++++++ dashboard/static/dashboard.html | 217 +++++++++++++++++++++++++++++ hblink3-dash.service | 35 +++++ run_dashboard.py | 11 ++ 9 files changed, 605 insertions(+) create mode 100644 dashboard/README.md create mode 100644 dashboard/config_sample.py create mode 100644 dashboard/requirements.txt create mode 100644 dashboard/server.py create mode 100644 dashboard/static/dashboard.html create mode 100644 hblink3-dash.service create mode 100644 run_dashboard.py diff --git a/.gitignore b/.gitignore index 192fd9b..ecc8d99 100755 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/README.md b/README.md index da5353c..d9642ab 100755 --- a/README.md +++ b/README.md @@ -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) diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..a22c251 --- /dev/null +++ b/dashboard/README.md @@ -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://: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. diff --git a/dashboard/config_sample.py b/dashboard/config_sample.py new file mode 100644 index 0000000..d7effe0 --- /dev/null +++ b/dashboard/config_sample.py @@ -0,0 +1,33 @@ +############################################################################### +# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS +# +# 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 diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt new file mode 100644 index 0000000..2735090 --- /dev/null +++ b/dashboard/requirements.txt @@ -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 diff --git a/dashboard/server.py b/dashboard/server.py new file mode 100644 index 0000000..c1aa5b3 --- /dev/null +++ b/dashboard/server.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS +# +# 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() diff --git a/dashboard/static/dashboard.html b/dashboard/static/dashboard.html new file mode 100644 index 0000000..cbabd1e --- /dev/null +++ b/dashboard/static/dashboard.html @@ -0,0 +1,217 @@ + + + + + +{{REPORT_NAME}} — HBlink3 + + + +
+
+

{{REPORT_NAME}}HBlink3 Monitor

+
HBlink: connecting…
+
+ +

Master Systems

+

Peer Systems

+

OpenBridge Systems

+

Conference Bridges

+

Call Log

+
+ + + + diff --git a/hblink3-dash.service b/hblink3-dash.service new file mode 100644 index 0000000..34f6c56 --- /dev/null +++ b/hblink3-dash.service @@ -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 diff --git a/run_dashboard.py b/run_dashboard.py new file mode 100644 index 0000000..76e6cd1 --- /dev/null +++ b/run_dashboard.py @@ -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()