dashboard: add Last Heard table and collapsible peer lists

- Last Heard: most-recent unique callers derived from the call log
  (User / Type / Source / TS / Talkgroup / When), collapsible with
  per-browser state; configurable via LAST_HEARD / LAST_HEARD_COUNT.
- Systems: a Show/Hide Peers button collapses each system's peer table;
  the system header and TS1/TS2 activity pills stay visible so live
  traffic is always in view. Default via SYSTEM_PEERS.
- Docs updated (README.md, INSTALL.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cort BuffingtonandClaude Opus 4.8 committed 2026-07-06 08:32:17 -05:00
1 parent 9df3ceb90c
commit 6abb437fa4
5 files changed
+131 -4

No files matched your search

+4
View File
@@ -92,6 +92,10 @@ Edit `dashboard/config.py`:
- `DMRLINK_IP` / `DMRLINK_PORT` — point at dmrlink3's `REPORT_PORT`.
- `WEB_PORT` — port the dashboard web server listens on (default `8080`).
- `PATH` — directory containing alias files (`peer_ids.json`, etc.).
- `LAST_HEARD` / `LAST_HEARD_COUNT` — default state (`'open'`/`'closed'`/`'off'`)
and length of the Last Heard table.
- `SYSTEM_PEERS` — default state (`'open'`/`'closed'`) of each system's
collapsible peer list; `'closed'` gives a compact view for many-system installs.
---
+2 -1
View File
@@ -161,7 +161,8 @@ No dmrlink3 connection is required. This is useful for understanding what the da
looks like under normal operating conditions before any traffic has been seen.
**Dashboard features:**
- Live IPSC system status: peer table with radio IDs, addresses, and connection state
- **Last Heard** — most-recent unique callers (user, call type, source peer, timeslot, talkgroup, and when), derived live from the call log; collapsible, with its open/closed state remembered per-browser (`LAST_HEARD` / `LAST_HEARD_COUNT`)
- Live IPSC system status: peer table with radio IDs, addresses, and connection state. Each system's peer list is collapsible with the **Show/Hide Peers** button — the system header and TS1/TS2 activity pills stay visible, so live traffic is always in view even when collapsed. Default state is set by `SYSTEM_PEERS` (`'open'`/`'closed'`) and per-system choices are remembered per-browser
- Per-system **TS1/TS2 activity pills** showing real-time slot state:
- **RX** (green) — receiving a call from a peer, with source radio, peer ID, TGID, and elapsed time
- **TX** (orange) — forwarding a bridged call, with originating radio, source peer, TGID, and elapsed time
+8
View File
@@ -24,6 +24,14 @@ WEB_PORT = 8080 # Port (must be > 1024 if not root)
# Call log
LOG_LINES = 300 # Number of recent call-log entries to retain
# Last Heard table — most-recent unique callers, shown at the top of the dashboard.
LAST_HEARD = 'open' # 'open' | 'closed' | 'off' (default state; 'off' hides it entirely)
LAST_HEARD_COUNT = 10 # Number of recent callers to list
# Systems display — default state for each system's collapsible peer/repeater list.
# Users can toggle each system open/closed in their own browser (remembered locally).
SYSTEM_PEERS = 'open' # 'open' | 'closed' ('closed' gives a compact view for many-system installs)
# Alias files — the dashboard owns downloading, storing, and refreshing these.
# Files live in the dashboard's own subdirectory (PATH = './') by default.
# Set TRY_DOWNLOAD = True to fetch fresh files from radioid.net on startup and
+16 -1
View File
@@ -62,6 +62,17 @@ try:
except ImportError:
FILTER_COUNTRIES = None
try:
from config import LAST_HEARD, LAST_HEARD_COUNT
except ImportError:
LAST_HEARD = 'open'
LAST_HEARD_COUNT = 10
try:
from config import SYSTEM_PEERS
except ImportError:
SYSTEM_PEERS = 'open'
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('dmrdash')
@@ -301,7 +312,11 @@ app = FastAPI(lifespan=lifespan)
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).replace('{{LOGO_HTML}}', LOGO_HTML)
return (html.replace('{{REPORT_NAME}}', REPORT_NAME)
.replace('{{LOGO_HTML}}', LOGO_HTML)
.replace('{{LAST_HEARD}}', str(LAST_HEARD))
.replace('{{LAST_HEARD_COUNT}}', str(LAST_HEARD_COUNT))
.replace('{{SYSTEM_PEERS}}', str(SYSTEM_PEERS)))
@app.get('/logo')
async def serve_logo():
+101 -2
View File
@@ -64,6 +64,13 @@
.sys-table th:nth-child(1),.sys-table td:nth-child(1){width:22%}
.sys-table th:nth-child(2),.sys-table td:nth-child(2){width:42%}
.sys-table th:nth-child(3),.sys-table td:nth-child(3){width:36%}
.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}
#lastHeardBody.hidden{display:none}
.peers-hdr .toggle-btn .sub{font-weight:400}
.sys-block.collapsed .sys-table,.sys-block.collapsed .empty{display:none}
</style>
</head>
<body>
@@ -76,6 +83,7 @@
<div id="conn" class="badge down">DMRlink3: connecting…</div>
</header>
<section id="lastHeardSection"><h2 class="lh-head">Last Heard <span id="lastHeardToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="lastHeardBody"><div id="lastHeard"></div></div></section>
<section><h2>Systems</h2><div id="systems"></div></section>
<section><h2>Conference Bridges</h2><div id="bridges"></div></section>
<section><h2>Call Log</h2><div id="log"></div></section>
@@ -84,6 +92,9 @@
<script>
const ST = { systems:{}, bridges:{}, active:{}, tx_active:{}, hang:{}, trunk_streams:{}, log:[], dmrlink:false };
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 SYSTEM_PEERS_CFG = '{{SYSTEM_PEERS}}'; // 'open' | 'closed' — default state for per-system peer lists
const el = id => document.getElementById(id);
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
@@ -182,6 +193,27 @@ function renderTrunkBlock(n, s){
return h;
}
// ---- per-system peer-list collapse (persisted per browser; SYSTEM_PEERS sets the default) ----
function sysCollapsedStore(){ try{ return JSON.parse(localStorage.getItem('dmrl_sys_collapsed')||'{}'); }catch(e){ return {}; } }
function sysIsCollapsed(n){
const st = sysCollapsedStore();
return Object.prototype.hasOwnProperty.call(st, n) ? !!st[n] : (SYSTEM_PEERS_CFG==='closed');
}
function sysSetCollapsed(n, val){
const st = sysCollapsedStore(); st[n] = val;
localStorage.setItem('dmrl_sys_collapsed', JSON.stringify(st));
}
function initSystemsCollapse(){
// Delegated so it survives renderSystems() rebuilding #systems on every event.
el('systems').addEventListener('click', ev=>{
const btn = ev.target.closest('.peers-toggle'); if(!btn) return;
const block = btn.closest('.sys-block'); if(!block) return;
const n = block.dataset.sys;
sysSetCollapsed(n, !sysIsCollapsed(n));
renderSystems();
});
}
function renderSystems(){
const names = Object.keys(ST.systems);
if(!names.length){ el('systems').innerHTML='<div class="empty">No systems (dmrlink3 not connected or no networks enabled)</div>'; return; }
@@ -206,7 +238,11 @@ function renderSystems(){
+ '<div class="slot-col"><div class="slot-label">TS2</div>'+sysTsPill(n,2)+'</div>'
+ '</div>'
+ '</div>';
h += '<div class="peers-hdr"><h3>Peers in IPSC ('+totalCount+')</h3></div>';
const collapsed = sysIsCollapsed(n);
h += '<div class="sys-block'+(collapsed?' collapsed':'')+'" data-sys="'+esc(n)+'">';
h += '<div class="peers-hdr"><span class="toggle-btn peers-toggle" title="Show or hide this system’s peers">'
+ '<span class="caret">'+(collapsed?'▸':'▾')+'</span>'+(collapsed?'Show Peers':'Hide Peers')
+ ' <span class="sub">('+totalCount+')</span></span></div>';
if(!totalCount){
h += '<div class="empty" style="margin-bottom:10px">No peers</div>';
@@ -263,6 +299,7 @@ function renderSystems(){
}
h += '</table>';
}
h += '</div>'; // close .sys-block (collapsible peer list)
}
el('systems').innerHTML = h;
}
@@ -311,6 +348,62 @@ function logRow(e){
}
function renderLog(){ el('log').innerHTML = ST.log.map(logRow).join(''); }
// ---------- last heard ----------
// Most-recent unique callers, derived from the ingress (non-TX) call log — no
// server state of its own. Deduped by source radio ID, newest first.
// The call type is carried inside the compound 'event' string
// (e.g. "GROUP VOICE START"); strip the direction/action tokens to label it.
function callTypeLabel(ev){
if(!ev) return '';
const t = ev.replace(/\s*(TX\s+)?(START|END)\s*$/i, '').trim();
return t.split(/\s+/).map(w => w ? w[0].toUpperCase()+w.slice(1).toLowerCase() : w).join(' ');
}
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){
if((e.event||'').includes('TX')) continue; // skip retransmit legs; keep true originators
const id = e.src==null ? '' : String(e.src);
if(!id || seen[id]) continue;
seen[id] = 1; rows.push(e);
if(rows.length >= LAST_HEARD_COUNT) break;
}
if(!rows.length){ box.innerHTML = '<div class="empty">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 t = e._arrived||0; // seconds
const when = lhActiveSrc(e.src)
? '<span class="status-ok">Active</span>'
: '<span class="js-lh mono" data-t="'+t+'">'+dur(Date.now()/1000 - t)+' ago</span>';
h += '<tr><td>'+idName(e.src_alias,e.src)+'</td>'
+ '<td class="sub">'+esc(callTypeLabel(e.event))+'</td>'
+ '<td class="mono sub">'+(e.peer!=null ? idName(e.peer_alias,e.peer) : esc(e.system||''))+'</td>'
+ '<td class="mono">TS'+esc(e.ts||'?')+'</td>'
+ '<td>'+idName(e.tgid_alias,e.tgid)+'</td>'
+ '<td>'+when+'</td></tr>';
}
box.innerHTML = h+'</table>';
}
function setLastHeardOpen(open){
el('lastHeardBody').classList.toggle('hidden', !open);
const t = el('lastHeardToggle');
t.querySelector('.caret').textContent = open ? '▾' : '▸';
t.querySelector('.toggle-label').textContent = open ? 'Hide' : 'Show';
}
function initLastHeard(){
const sec = el('lastHeardSection'); if(!sec) return;
if(LAST_HEARD_CFG==='off'){ sec.style.display='none'; return; }
const saved = localStorage.getItem('lastHeardOpen'); // per-browser override wins over config default
setLastHeardOpen(saved==null ? (LAST_HEARD_CFG!=='closed') : (saved==='1'));
el('lastHeardToggle').addEventListener('click', ()=>{
const open = el('lastHeardBody').classList.contains('hidden'); // currently hidden → we're opening it
setLastHeardOpen(open);
localStorage.setItem('lastHeardOpen', open?'1':'0');
});
renderLastHeard();
}
// ---- ticker: countdown timers + slot pill elapsed ----
setInterval(()=>{
const now = Date.now();
@@ -329,6 +422,9 @@ setInterval(()=>{
document.querySelectorAll('.js-since').forEach(s=>{
s.textContent = dur((+s.dataset.base) + (now - (+s.dataset.arr))/1000);
});
document.querySelectorAll('.js-lh').forEach(s=>{
s.textContent = dur(now/1000 - (+s.dataset.t)) + ' ago';
});
}, 1000);
// ---- websocket ----
@@ -338,7 +434,7 @@ function setConn(up){
c.className = 'badge '+(up?'up':'down');
c.textContent = 'DMRlink3: '+(up?'connected':'disconnected');
}
function renderAll(){ renderSystems(); renderBridges(); renderLog(); }
function renderAll(){ renderSystems(); renderBridges(); renderLog(); renderLastHeard(); }
function applyBridgeEvent(data){
data._arrived = data._arrived || (Date.now()/1000);
@@ -362,6 +458,7 @@ function applyBridgeEvent(data){
if(ST.log.length > 400) ST.log.pop();
renderSystems();
renderLog();
renderLastHeard();
}
let sock = null;
@@ -513,6 +610,8 @@ if(new URLSearchParams(location.search).has('demo')){
} else {
connect();
}
initLastHeard();
initSystemsCollapse();
</script>
</body>
</html>