mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 12:26:30 +00:00
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:
1 parent
9df3ceb90c
commit
6abb437fa4
5 files changed
+131
-4
No files matched your search
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
Reference in new issue
Block a user