Files
Cort BuffingtonandClaude Opus 5 0b591da73f Show OpenBridge trunks without a browser refresh
The dashboard's OBP support was complete except for the browser's event
switch, which had no case for openbridge_connected -- so state.openbridges
was only ever populated by initial_state. The core emits the event in
response to sync_request, after broadcast_hblink_status has already pushed
initial_state on socket connect, so a tab open across an hblink restart saw
an empty list, updateOpenbridges hid the section, and only a page reload
brought it back.

Handle openbridge_connected and openbridge_disconnected alongside the
outbound cases. Two details differ from a repeater or outbound connection,
because OBP has no handshake to observe: the event is re-sent on every
sync_request, so this upserts and plays no connect tone, preserving the
original connected_at rather than restarting the uptime counter; and the
core's payload carries no connected_at at all -- the server stamps its own
copy from the event envelope, so the browser does the same or the card
renders no uptime.

openbridge_disconnected is wired but currently dead. A bound socket has
nothing to drop, so nothing emits it, but the server already handles it and
the card should follow if that changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 17:23:29 -05:00

2830 lines
129 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HBlink4 Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 12px;
}
.container { max-width: 1600px; margin: 0 auto; }
header {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-branding {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
height: 72px;
width: auto;
}
h1 { color: #38bdf8; font-size: 30px; }
.status-badges {
display: flex;
gap: 8px;
align-items: center;
}
.audio-toggle {
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
border: 2px solid #64748b;
}
.audio-toggle:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.audio-toggle.enabled {
background: #8b5cf6;
color: white;
border-color: #8b5cf6;
}
.audio-toggle.disabled {
background: #374151;
color: #9ca3af;
border-color: #4b5563;
}
.network-info-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
background: #0ea5e9;
color: #ffffff;
border: 1px solid #38bdf8;
}
.network-info-btn:hover {
background: #0284c7;
border-color: #0ea5e9;
transform: translateY(-1px);
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: #1e293b;
border-radius: 12px;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border: 1px solid #334155;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background: #1e293b;
z-index: 1;
}
.modal-header h2 {
margin: 0;
color: #38bdf8;
font-size: 22px;
}
.modal-close {
background: #374151;
border: none;
color: #e2e8f0;
font-size: 26px;
cursor: pointer;
width: 36px;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.modal-close:hover {
background: #4b5563;
transform: scale(1.1);
}
.modal-body {
padding: 24px;
}
.modal-section {
margin-bottom: 24px;
}
.modal-section:last-child {
margin-bottom: 0;
}
.modal-section h3 {
color: #94a3b8;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 12px 0;
font-weight: 600;
}
.modal-table {
width: 100%;
border-collapse: collapse;
}
.modal-table td {
padding: 8px 0;
border-bottom: 1px solid #334155;
}
.modal-table td:first-child {
color: #94a3b8;
font-weight: 500;
width: 180px;
}
.modal-table td:last-child {
color: #e2e8f0;
}
.modal-table tr:last-child td {
border-bottom: none;
}
.modal-highlight {
color: #38bdf8;
font-weight: 600;
}
.modal-link {
color: #38bdf8;
text-decoration: none;
}
.modal-link:hover {
text-decoration: underline;
}
.repeater-header {
cursor: pointer;
transition: all 0.2s ease;
}
.repeater-header:hover {
background: #1e40af;
transform: translateY(-1px);
}
.repeater-header-warning {
cursor: pointer;
transition: all 0.2s ease;
}
.repeater-header-warning:hover {
background: #dc2626;
transform: translateY(-1px);
}
.connection-status {
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
user-select: none;
}
.connection-status.clickable {
cursor: pointer;
}
.connection-status.clickable:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.connection-status.connected { background: #10b981; color: white; }
.connection-status.warning { background: #f59e0b; color: white; }
.connection-status.disconnected {
background: #ef4444;
color: white;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.announcement-banner {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: #ffffff;
padding: 16px 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
border: 2px solid #fbbf24;
font-size: 13px;
line-height: 1.6;
font-weight: 500;
}
.announcement-banner strong {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stats-bar {
display: grid;
/* Fixed-width tracks: all pills equal, sized to fit the longest
label ("Retransmitted Calls Today"), no stretching to fill. */
grid-template-columns: repeat(auto-fit, 170px);
gap: 10px;
margin-bottom: 15px;
}
.stat-card {
background: #1e293b;
padding: 8px 12px;
border-radius: 6px;
} .stat-label { color: #94a3b8; font-size: 10px; margin-bottom: 5px; white-space: nowrap; }
.stat-value { color: #e2e8f0; font-size: 20px; font-weight: 600; }
.card {
background: #1e293b;
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
margin-bottom: 15px;
}
.card h2 {
color: #94a3b8;
font-size: 14px;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.section-header {
color: #94a3b8;
font-size: 14px;
margin-bottom: 12px;
margin-top: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Collapsible section headers (caret toggles the section body) */
.collapsible {
cursor: pointer;
user-select: none;
}
.collapse-caret {
display: inline-block;
width: 0.9em;
margin-right: 3px;
font-size: 1.2em;
color: #64748b;
transition: transform 0.15s ease;
}
.collapsible:hover .collapse-caret {
color: #38bdf8;
}
.collapsed .collapse-caret {
transform: rotate(-90deg);
}
/* Repeater Card Layout - Compact Version */
/* ORIGINAL VALUES FOR REVERT: padding: 2px, margin-bottom: 10px, border-radius: 8px */
.repeater-card {
background: #1e293b;
border-radius: 6px;
padding: 1px;
margin-bottom: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
flex-wrap: wrap; /* Allow wrapping when too narrow */
justify-content: space-between;
align-items: stretch;
overflow: hidden; /* Prevent children from overflowing */
min-width: 0; /* Allow shrinking */
}
.repeater-info {
flex: 0 0 auto; /* Don't grow, don't shrink, auto width */
padding: 6px;
min-width: 200px; /* Minimum width before wrapping */
max-width: 400px; /* Don't let metadata section get too wide */
}
.repeater-header {
font-size: 14px;
font-weight: 600;
color: #38bdf8;
margin-bottom: 4px;
}
.repeater-location {
color: #94a3b8;
font-size: 12px;
margin-bottom: 12px;
}
.repeater-meta {
display: grid;
grid-template-columns: 140px auto 1fr;
grid-template-rows: auto auto;
gap: 2px 10px;
font-size: 0.72em;
margin-top: 2px;
}
/* Hide metadata by default on medium/small screens - only show on large or when explicitly stacked */
@media (max-width: 1024px) {
.repeater-meta {
display: none; /* Hide by default when wrapping might occur */
}
}
.repeater-meta-item {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.repeater-meta-label {
color: #94a3b8;
font-weight: 500;
}
.repeater-meta-label.rpto-tgs {
color: #60a5fa;
}
.translated-tg {
color: #fbbf24;
cursor: help;
}
/* Slot Display */
.slots-container {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
flex: 0 1 auto; /* Don't grow, allow shrink, auto width */
min-width: min-content; /* Shrink to fit content */
max-width: 100%; /* Don't exceed container */
align-self: flex-end; /* Align to right side */
margin-left: auto; /* Push to right */
}
.slot {
background: #0f172a;
border-radius: 4px;
padding: 6px 10px;
border-left: 3px solid #334155;
display: grid;
grid-template-columns: 55px 70px 75px 110px 55px 70px; /* Fixed optimal widths with padding */
gap: 10px;
align-items: center;
font-size: 11px;
min-width: 0; /* Allow grid items to shrink */
width: max-content; /* Shrink to fit content */
}
.slot.idle {
border-left-color: #334155;
}
.slot.receiving {
border-left-color: #10b981;
background: linear-gradient(90deg, rgba(16,185,129,0.1) 0%, rgba(15,23,42,1) 100%);
}
.slot.transmitting {
border-left-color: #ef4444;
background: linear-gradient(90deg, rgba(239,68,68,0.1) 0%, rgba(15,23,42,1) 100%);
}
.slot.hang_time {
border-left-color: #6366f1;
background: linear-gradient(90deg, rgba(99,102,241,0.1) 0%, rgba(15,23,42,1) 100%);
}
.slot-col {
display: flex;
flex-direction: column;
gap: 1px;
}
.slot-col-label {
color: #64748b;
font-size: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slot-col-value {
color: #e2e8f0;
font-weight: 500;
}
.slot-col-value.empty {
color: #475569;
font-style: italic;
}
.slot-col-value.highlight {
color: #38bdf8;
font-weight: 600;
}
.slot-status-badge {
padding: 2px 6px;
border-radius: 6px;
font-size: 7px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: center;
}
.slot-status-badge.idle {
background: #334155;
color: #94a3b8;
}
.slot-status-badge.receiving {
background: #10b981;
color: white;
}
.slot-status-badge.transmitting {
background: #ef4444;
color: white;
}
.slot-status-badge.hang_time {
background: #6366f1;
color: white;
}
.slot-status-badge.data {
background: #fbbf24; /* gold */
color: #1e293b;
}
/* OpenBridge active-stream pills — flow left→right, wrap as a group */
.obp-streams {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.obp-stream-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: #0f213b;
border: 1px solid #334155;
border-radius: 999px;
padding: 3px 10px;
font-size: 11px;
white-space: nowrap;
}
.obp-stream-pill.hang_time {
opacity: 0.75;
}
.obp-stream-pill.idle {
color: #64748b;
}
.repeater-header-warning {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: #fbbf24; /* Yellow warning color */
}
.slot-talker {
color: #94a3b8;
font-size: 10px;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.status.active { background: #10b981; color: white; }
.status.hang { background: #f59e0b; color: white; }
.status.connected { background: #3b82f6; color: white; }
.event {
padding: 12px;
border-left: 3px solid #3b82f6;
margin-bottom: 8px;
background: #334155;
border-radius: 4px;
font-size: 11px;
}
.event .time {
color: #64748b;
font-size: 9px;
margin-bottom: 4px;
}
.event .type {
display: inline-block;
background: #475569;
padding: 2px 8px;
border-radius: 4px;
font-size: 8px;
font-weight: 600;
text-transform: uppercase;
margin-right: 8px;
}
.empty-state {
text-align: center;
color: #64748b;
padding: 40px 20px;
font-style: italic;
}
.tg-list {
color: #64748b;
font-size: 10px;
}
/* Responsive Design for Mobile/Tablet */
/* Container query simulation: when slots wrap below repeater info */
@media (max-width: 800px) {
/* At this width, slots will wrap below - hide metadata */
.repeater-card {
flex-direction: column; /* Force vertical stacking */
}
.repeater-info {
border-bottom: 2px solid #0f172a;
min-width: 100%; /* Full width when stacked */
}
.slots-container {
min-width: 100%; /* Full width when stacked */
max-width: 100%;
align-self: stretch; /* Take full width */
margin-left: 0; /* Reset right alignment */
}
/* Hide metadata when wrapped, keep only identifier */
.repeater-meta {
display: none;
}
.repeater-header {
font-size: 12px;
margin-bottom: 4px;
}
/* Compact slot display - optimized fixed widths */
.slot {
grid-template-columns: 50px 65px 70px 100px 50px 65px;
gap: 8px;
font-size: 11px;
width: 100%; /* Take full width on mobile */
}
}
@media (min-width: 801px) and (max-width: 1024px) {
/* Tablets: only show metadata when we're in proper vertical stack mode */
.repeater-card {
flex-direction: column; /* Force vertical stacking */
}
.repeater-info {
border-bottom: 2px solid #0f172a;
}
.slots-container {
min-width: 100%;
max-width: 100%;
align-self: stretch; /* Take full width */
margin-left: 0; /* Reset right alignment */
}
/* Adjust slot grid for tablets - optimized fixed widths */
.slot {
grid-template-columns: 52px 68px 72px 105px 52px 68px;
gap: 8px;
font-size: 11px;
width: 100%; /* Take full width on tablet */
}
/* Show metadata ONLY when forced to column layout (not wrapped) */
.repeater-meta {
display: grid !important; /* Override the hide rule - safe here because flex-direction: column */
grid-template-columns: 1fr 1fr;
gap: 8px 12px;
}
}
/* Wide screens: metadata visible side-by-side */
@media (min-width: 1025px) {
.repeater-meta {
display: grid !important; /* Show on wide screens */
}
}
@media (max-width: 600px) {
/* Small phones: further compaction */
body {
padding: 8px;
}
h1 {
font-size: 22px;
}
.logo {
height: 48px;
}
.stats-bar {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.stat-card {
padding: 8px;
}
.stat-value {
font-size: 16px;
}
/* Even more compact slot display - 5 columns */
.slot {
grid-template-columns: 45px 60px 65px 95px 55px;
gap: 6px;
font-size: 10px;
padding: 6px 8px;
width: 100%; /* Take full width */
}
.slot-col-label {
font-size: 8px;
}
.slot-col-value {
font-size: 10px;
}
.repeater-info {
padding: 6px 8px;
}
}
@media (max-width: 480px) {
/* Very small phones: ultra-compact, 4 columns (hides columns 3,4) */
.slot {
grid-template-columns: 42px 58px 62px 55px;
gap: 4px;
font-size: 9px;
padding: 6px;
width: 100%; /* Take full width */
}
/* Hide destination and duration columns on tiny screens */
.slot .slot-col:nth-child(3),
.slot .slot-col:nth-child(4) {
display: none;
}
.stats-bar {
grid-template-columns: 1fr;
}
.section-header {
font-size: 12px;
}
}
/* Outbound connection status styles */
.status-connected { color: #34d399; font-weight: 600; }
.status-warning { color: #fbbf24; font-weight: 600; }
.status-error { color: #f87171; font-weight: 600; }
.status-disconnected { color: #94a3b8; font-weight: 600; }
.outbound-header-connected {
font-size: 14px;
font-weight: 600;
color: #38bdf8;
margin-bottom: 4px;
}
.outbound-header-warning {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: #fbbf24;
}
.outbound-header-disconnected {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: #475569; /* Dark blue/gray for disconnected/connecting */
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-branding">
<img src="/static/HBlink.png" alt="HBlink" class="logo">
<div>
<h1 id="dashboardTitle">HBlink4 Dashboard</h1>
<div style="color: #64748b; font-size: 12px; margin-top: 4px;" id="serverName"></div>
</div>
</div>
<div class="status-badges">
<a class="network-info-btn" id="networkInfoBtn" style="display: none;" target="_blank">🌐 Network Info</a>
<div class="connection-status clickable warning" id="wsStatus" title="Connecting to dashboard server...">Connecting...</div>
<div class="connection-status warning" id="hblinkStatus" title="Waiting for connection status...">Unknown</div>
<div class="audio-toggle disabled" id="audioToggle" title="Click to enable audio notifications">🔇 Notifications</div>
</div>
</header>
<!-- Announcement Banner (hidden by default, shown if announcement text exists) -->
<div id="announcementBanner" class="announcement-banner" style="display: none;">
<strong>📢 Announcement:</strong> <span id="announcementText"></span>
</div>
<h2 class="section-header">Statistics</h2>
<div class="stats-bar">
<div class="stat-card">
<div class="stat-label">Connections</div>
<div class="stat-value" id="statConnections">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Streams</div>
<div class="stat-value" id="statStreams">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Calls Today</div>
<div class="stat-value" id="statTotalCalls">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Retransmitted Calls Today</div>
<div class="stat-value" id="statRetransmittedCalls">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Traffic Today</div>
<div class="stat-value" id="statTotalDuration">0:00:00</div>
</div>
</div>
<!-- Last Heard / Active Streams (collapsible: minimized filters to active calls) -->
<h2 class="section-header collapsible" id="lastHeardHeader" data-section="lastHeard" style="margin-top: 20px; margin-bottom: 10px;">
<span class="collapse-caret">▾</span><span id="lastHeardTitle">Last Heard</span>
<span id="userDbStatus" style="font-size: 0.55em; color: #9ca3af; font-weight: 400; margin-left: 10px;"></span>
</h2>
<div class="card" id="lastHeardBody">
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
<thead>
<tr style="background-color: #374151; color: white;">
<th style="padding: 8px; text-align: left;">Radio ID</th>
<th style="padding: 8px; text-align: left;">Callsign/Alias</th>
<th style="padding: 8px; text-align: left;">Source</th>
<th style="padding: 8px; text-align: center;">Slot</th>
<th style="padding: 8px; text-align: left;">Destination</th>
<th style="padding: 8px; text-align: left;">Last Heard</th>
</tr>
</thead>
<tbody id="lastHeardTable">
<tr>
<td colspan="6" style="padding: 8px 20px; text-align: center; color: #9ca3af;">
No users heard yet
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- OpenBridge Trunks (positioned above Repeaters) -->
<div id="openbridgeSection" style="display: none;">
<h2 class="section-header collapsible" data-section="openbridge" data-collapse-target="openbridgeBody" style="margin-top: 20px;"><span class="collapse-caret">▾</span>↔️ OpenBridge <span id="openbridgeCount" style="color: #38bdf8; font-weight: 700;"></span></h2>
<div class="card" id="openbridgeBody">
<div id="openbridgesContainer"></div>
</div>
</div>
<!-- Repeaters Section -->
<div id="repeatersSection" style="display: none;">
<h2 class="section-header collapsible" data-section="repeaters" data-collapse-target="repeatersBody"><span class="collapse-caret">▾</span>📶 Repeaters <span id="repeaterCount" style="color: #38bdf8; font-weight: 700;"></span></h2>
<div class="card" id="repeatersBody">
<div id="repeatersContainer"></div>
</div>
</div>
<!-- Hotspots Section -->
<div id="hotspotsSection" style="display: none;">
<h2 class="section-header collapsible" data-section="hotspots" data-collapse-target="hotspotsBody" style="margin-top: 20px;"><span class="collapse-caret">▾</span>📱 Hotspots <span id="hotspotCount" style="color: #38bdf8; font-weight: 700;"></span></h2>
<div class="card" id="hotspotsBody">
<div id="hotspotsContainer"></div>
</div>
</div>
<!-- Network Inbound Section -->
<div id="networksSection" style="display: none;">
<h2 class="section-header collapsible" data-section="networks" data-collapse-target="networksBody" style="margin-top: 20px;"><span class="collapse-caret">▾</span>🔗 Network Inbound <span id="networkCount" style="color: #38bdf8; font-weight: 700;"></span></h2>
<div class="card" id="networksBody">
<div id="networksContainer"></div>
</div>
</div>
<!-- Unknown Connections Section (fallback for unrecognized software) -->
<div id="unknownSection" style="display: none;">
<h2 class="section-header collapsible" data-section="unknown" data-collapse-target="unknownBody" style="margin-top: 20px;"><span class="collapse-caret">▾</span>❓ Other Connections <span id="unknownCount" style="color: #38bdf8; font-weight: 700;"></span></h2>
<div class="card" id="unknownBody">
<div id="unknownContainer"></div>
</div>
</div>
<!-- Network Outbound -->
<div id="outboundSection" style="display: none;">
<h2 class="section-header collapsible" data-section="outbound" data-collapse-target="outboundBody" style="margin-top: 20px;"><span class="collapse-caret">▾</span>🔗 Network Outbound <span id="outboundCount" style="color: #38bdf8; font-weight: 700;"></span></h2>
<div class="card" id="outboundBody">
<div id="outboundsContainer"></div>
</div>
</div>
<!-- Recent Events -->
<div id="eventsSection">
<h2 class="section-header collapsible" data-section="events" data-collapse-target="eventsBody" style="margin-top: 20px;"><span class="collapse-caret">▾</span>Recent Events</h2>
<div class="card" id="eventsBody">
<div id="eventsLog"></div>
</div>
</div>
</div>
<script>
let ws = null;
let reconnectTimeout = null;
// Data-call persistence-of-vision (gold DATA pill).
// A data call is typically one DMR burst (~60ms) that ends via
// fast_terminator ~200ms later — far too brief for the eye to catch.
// We hold the DATA pill on the slot for DATA_PERSIST_MS after the
// last burst so users can actually see it. Keyed per slot so
// back-to-back bursts just refresh the timer.
const DATA_PERSIST_MS = 3000;
const dataPersistTimers = new Map(); // key: slot identity, value: timeout id
function dataPersistKey(connectionType, connectionId, slot) {
return `${connectionType}:${connectionId}:${slot}`;
}
function clearDataPersist(key) {
const tid = dataPersistTimers.get(key);
if (tid !== undefined) {
clearTimeout(tid);
dataPersistTimers.delete(key);
}
}
function scheduleDataPersistDrop(key, connectionType, findPredicate) {
clearDataPersist(key);
const tid = setTimeout(() => {
dataPersistTimers.delete(key);
// Drop the data stream from local state and re-render.
state.streams = state.streams.filter(s => !findPredicate(s));
if (connectionType === 'outbound') {
const outboundList = Array.isArray(state.outbounds)
? state.outbounds : Object.values(state.outbounds || {});
updateOutbounds(outboundList);
} else if (connectionType === 'openbridge') {
updateOpenbridges(state.openbridges);
} else if (state.repeaters) {
updateRepeaters(state.repeaters);
}
}, DATA_PERSIST_MS);
dataPersistTimers.set(key, tid);
}
let state = {
repeaters: [],
repeater_details: {}, // Detailed info for each repeater
outbounds: [], // Outbound connections
openbridges: [], // OpenBridge trunks
streams: [],
events: [],
last_heard: [],
last_heard_stats: {},
stats: {
total_calls_today: 0,
total_duration_today: 0,
retransmitted_calls: 0
},
config: {},
user_db_status: null
};
// Audio notification system
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
let audioEnabled = localStorage.getItem('audioNotifications') === 'true';
function updateAudioToggle() {
const toggle = document.getElementById('audioToggle');
if (audioEnabled) {
toggle.className = 'audio-toggle enabled';
toggle.textContent = '🔊 Notifications';
toggle.title = 'Click to disable audio notifications';
} else {
toggle.className = 'audio-toggle disabled';
toggle.textContent = '🔇 Notifications';
toggle.title = 'Click to enable audio notifications';
}
}
function toggleAudio() {
audioEnabled = !audioEnabled;
localStorage.setItem('audioNotifications', audioEnabled.toString());
updateAudioToggle();
// Resume audio context if enabling (required by browsers)
if (audioEnabled && audioContext.state === 'suspended') {
audioContext.resume();
}
}
function playTone(frequency, duration) {
if (!audioEnabled) return;
// Resume audio context if suspended (required by browsers)
if (audioContext.state === 'suspended') {
audioContext.resume();
}
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = frequency;
oscillator.type = 'sine';
// Fade in and out to avoid clicks
const now = audioContext.currentTime;
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(0.3, now + 0.01);
gainNode.gain.linearRampToValueAtTime(0.3, now + duration - 0.01);
gainNode.gain.linearRampToValueAtTime(0, now + duration);
oscillator.start(now);
oscillator.stop(now + duration);
}
function playConnectTone() {
playTone(880, 0.15);
}
function playDisconnectTone() {
playTone(440, 0.15);
}
function playAlertTone() {
playTone(2475, 0.05);
}
// Load configuration
async function loadConfig() {
try {
const response = await fetch('/api/config');
const config = await response.json();
state.config = config;
// Update page title and server name
document.title = config.dashboard_title || 'HBlink4 Dashboard';
document.getElementById('dashboardTitle').textContent = config.dashboard_title || 'HBlink4 Dashboard';
document.getElementById('serverName').textContent = config.server_name || '';
// Show announcement banner if enabled and text exists
if (config.announcement && config.announcement.enabled && config.announcement.text && config.announcement.text.trim()) {
document.getElementById('announcementText').textContent = config.announcement.text;
document.getElementById('announcementBanner').style.display = 'block';
} else {
document.getElementById('announcementBanner').style.display = 'none';
}
// Configure network info button
const networkInfoBtn = document.getElementById('networkInfoBtn');
if (config.network_info && config.network_info.enabled && config.network_info.url) {
networkInfoBtn.textContent = config.network_info.button_text || '🌐 Network Info';
networkInfoBtn.href = config.network_info.url;
networkInfoBtn.style.display = 'inline-block';
} else {
networkInfoBtn.style.display = 'none';
}
} catch (error) {
console.error('Failed to load config:', error);
}
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
updateWebSocketStatus(true);
};
ws.onclose = () => {
updateWebSocketStatus(false);
// When we lose WebSocket, we also can't know HBlink status
updateHBlinkStatus(false);
// Reconnect after 3 seconds
reconnectTimeout = setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
// Update UI immediately on error
updateWebSocketStatus(false);
updateHBlinkStatus(false);
};
ws.onmessage = (event) => {
// Ignore plain-text keepalive responses from server
if (event.data === 'pong') return;
let data;
try {
data = JSON.parse(event.data);
} catch (e) {
console.warn('Received non-JSON WebSocket message:', event.data);
return;
}
if (data.type === 'initial_state') {
state.repeaters = data.data.repeaters;
state.outbounds = data.data.outbounds || [];
state.openbridges = data.data.openbridges || [];
state.streams = data.data.streams;
state.events = data.data.events;
state.stats = data.data.stats;
state.last_heard = data.data.last_heard || [];
state.user_db_status = data.data.user_db_status || null;
// Update HBlink connection status
updateHBlinkStatus(data.data.hblink_connected || false);
updateRepeaters(state.repeaters);
updateOutbounds(state.outbounds);
updateOpenbridges(state.openbridges);
updateStreams(state.streams);
updateEvents(state.events);
updateStats(state.stats, state.repeaters, state.streams);
updateLastHeard(state.last_heard);
updateUserDbStatus(state.user_db_status);
} else if (data.type === 'hblink_status') {
// HBlink4 connection status changed
updateHBlinkStatus(data.data.connected);
} else if (data.type === 'stats_reset') {
// Midnight reset - update stats immediately
state.stats = data.data;
updateStats(state.stats, state.repeaters, state.streams);
} else if (data.type === 'user_db_status') {
// User database refresh completed (success or failure)
state.user_db_status = data.data;
updateUserDbStatus(state.user_db_status);
} else {
// Process event immediately for real-time updates
processEventUpdate(data);
handleEvent(data);
}
};
}
function formatDuration(seconds) {
if (seconds >= 31536000) { // 365 days
const years = Math.floor(seconds / 31536000);
const days = Math.floor((seconds % 31536000) / 86400);
return `${years}y ${days}d`;
} else if (seconds >= 86400) { // 24 hours
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}:${minutes.toString().padStart(2, '0')}`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
}
async function showRepeaterDetails(repeaterId) {
try {
// Fetch detailed stats from API
const response = await fetch(`/api/repeater/${repeaterId}`);
if (!response.ok) {
console.error('Failed to fetch repeater details');
return;
}
const details = await response.json();
// Create modal
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
// Select icon based on connection type
const connectionIcons = {
'repeater': '📶',
'hotspot': '📱',
'network': '🔗',
'unknown': '❓'
};
const icon = connectionIcons[details.connection_type] || '📶';
modal.innerHTML = `
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>${icon} ${details.callsign} (${details.repeater_id})</h2>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">×</button>
</div>
<div class="modal-body">
<div class="modal-section">
<h3>🔐 Access Control</h3>
<table class="modal-table">
<tr>
<td>Connection Category:</td>
<td class="modal-highlight">${details.access_control.connection_category}</td>
</tr>
${details.access_control.category_description ? `
<tr>
<td>Category Description:</td>
<td>${details.access_control.category_description}</td>
</tr>
` : ''}
<tr>
<td>TG Source:</td>
<td>${details.access_control.talkgroups_source}</td>
</tr>
<tr>
<td>RPTO Received:</td>
<td>${details.access_control.rpto_received ? '✓ Yes' : '✗ No'}</td>
</tr>
</table>
</div>
<div class="modal-section">
<h3>📡 Connection</h3>
<table class="modal-table">
<tr>
<td>Address:</td>
<td>${details.connection.address}</td>
</tr>
<tr>
<td>Uptime:</td>
<td>${formatDuration(details.connection.uptime_seconds)}</td>
</tr>
<tr>
<td>Status:</td>
<td class="modal-highlight">${details.connection.status}</td>
</tr>
<tr>
<td>Missed Pings:</td>
<td ${details.connection.missed_pings > 0 ? 'style="color: #fbbf24;"' : ''}>${details.connection.missed_pings}</td>
</tr>
</table>
</div>
<div class="modal-section">
<h3>📍 Location</h3>
<table class="modal-table">
<tr>
<td>Location:</td>
<td>${details.location.location || 'Not specified'}</td>
</tr>
${details.location.latitude && details.location.longitude ? `
<tr>
<td>Coordinates:</td>
<td>${details.location.latitude}, ${details.location.longitude}</td>
</tr>
` : ''}
${details.location.height ? `
<tr>
<td>Height:</td>
<td>${details.location.height}m</td>
</tr>
` : ''}
</table>
</div>
<div class="modal-section">
<h3>📻 Frequencies & Settings</h3>
<table class="modal-table">
${details.frequencies.rx_freq ? `
<tr>
<td>RX Frequency:</td>
<td>${(parseFloat(details.frequencies.rx_freq) / 1000000).toFixed(4)} MHz</td>
</tr>
` : ''}
${details.frequencies.tx_freq ? `
<tr>
<td>TX Frequency:</td>
<td>${(parseFloat(details.frequencies.tx_freq) / 1000000).toFixed(4)} MHz</td>
</tr>
` : ''}
${details.frequencies.tx_power ? `
<tr>
<td>TX Power:</td>
<td>${details.frequencies.tx_power}W</td>
</tr>
` : ''}
${details.frequencies.colorcode ? `
<tr>
<td>Color Code:</td>
<td>${details.frequencies.colorcode}</td>
</tr>
` : ''}
${details.frequencies.slots ? `
<tr>
<td>Slots:</td>
<td>${details.frequencies.slots}</td>
</tr>
` : ''}
</table>
</div>
<div class="modal-section">
<h3>📢 Talkgroups</h3>
<table class="modal-table">
<tr>
<td>Timeslot 1:</td>
<td>${
details.access_control.slot1_talkgroups === null
? 'All TGs Allowed'
: Array.isArray(details.access_control.slot1_talkgroups) && details.access_control.slot1_talkgroups.length === 0
? 'All TGs Denied'
: Array.isArray(details.access_control.slot1_talkgroups) && details.access_control.slot1_talkgroups.length > 0
? details.access_control.slot1_talkgroups.join(', ')
: '<span style="color: #6b7280;">Unknown</span>'
}</td>
</tr>
<tr>
<td>Timeslot 2:</td>
<td>${
details.access_control.slot2_talkgroups === null
? 'All TGs Allowed'
: Array.isArray(details.access_control.slot2_talkgroups) && details.access_control.slot2_talkgroups.length === 0
? 'All TGs Denied'
: Array.isArray(details.access_control.slot2_talkgroups) && details.access_control.slot2_talkgroups.length > 0
? details.access_control.slot2_talkgroups.join(', ')
: '<span style="color: #6b7280;">Unknown</span>'
}</td>
</tr>
</table>
</div>
<div class="modal-section">
<h3>ℹ️ Metadata</h3>
<table class="modal-table">
<tr>
<td>Software ID:</td>
<td>${details.metadata.software_id || '<span style="color: #6b7280;">Not reported</span>'}</td>
</tr>
<tr>
<td>Connection Type:</td>
<td class="modal-highlight">${details.connection_type || 'unknown'}</td>
</tr>
${details.metadata.package_id ? `
<tr>
<td>Package ID:</td>
<td>${details.metadata.package_id}</td>
</tr>
` : ''}
${details.metadata.description ? `
<tr>
<td>Description:</td>
<td>${details.metadata.description}</td>
</tr>
` : ''}
${details.metadata.url ? `
<tr>
<td>URL:</td>
<td><a href="${details.metadata.url}" class="modal-link" target="_blank">${details.metadata.url}</a></td>
</tr>
` : ''}
</table>
</div>
<div class="modal-section">
<h3>📊 Statistics (Today)</h3>
<table class="modal-table">
<tr>
<td>Total Streams:</td>
<td>${details.statistics.total_streams_today}</td>
</tr>
<tr>
<td>Slot 1 Active:</td>
<td>${details.statistics.slot1_active ? '✓ Yes' : '✗ No'}</td>
</tr>
<tr>
<td>Slot 2 Active:</td>
<td>${details.statistics.slot2_active ? '✓ Yes' : '✗ No'}</td>
</tr>
</table>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
} catch (error) {
console.error('Error showing repeater details:', error);
alert('Failed to load repeater details. Please try again.');
}
}
function updateWebSocketStatus(connected) {
const el = document.getElementById('wsStatus');
if (connected) {
el.textContent = '✓ Live Data';
el.className = 'connection-status clickable connected';
el.title = 'Live Data: Browser connected to the dashboard providing real-time updates - Click to reload';
} else {
el.textContent = '🔄 Click to Reconnect';
el.className = 'connection-status clickable disconnected';
el.title = 'Live Data: Browser disconnected from dashboard server - Click to reconnect';
}
}
function updateHBlinkStatus(connected) {
const el = document.getElementById('hblinkStatus');
if (connected) {
el.textContent = '✓ DMR Server';
el.className = 'connection-status connected';
el.title = 'DMR Server: Dashboard is connected to the DMR server';
} else {
el.textContent = '✗ DMR Server';
el.className = 'connection-status warning';
el.title = 'DMR Server: Dashboard is not connected to the DMR server';
// Clear stale data from display
state.repeaters = {};
state.streams = [];
// Clear all connection groups, events, and last-heard via the
// normal render paths. (The previous code targeted element IDs
// that don't exist — repeatersList/eventsList/lastHeardList — so
// the guarded blocks silently no-op'd and stale data lingered.
// These also clear hotspots/networks/unknown, not just repeaters.)
updateRepeaters([]);
updateEvents([]);
updateLastHeard([]);
// Reset stats to zero
document.getElementById('statConnections').textContent = '0';
document.getElementById('statStreams').textContent = '0';
}
}
function updateStats(stats, repeaters, streams) {
// Count connected inbound repeaters
const inboundCount = Array.isArray(repeaters) ? repeaters.filter(r => r.status === 'connected').length :
(repeaters ? Object.keys(repeaters).length : 0);
// Count connected outbound connections
const outboundList = Array.isArray(state.outbounds) ? state.outbounds : Object.values(state.outbounds || {});
const outboundCount = outboundList.filter(o => o.status === 'connected').length;
document.getElementById('statConnections').textContent = inboundCount + outboundCount;
document.getElementById('statStreams').textContent =
Array.isArray(streams) ? streams.filter(s => s.status === 'active').length :
(streams ? Object.keys(streams).length : 0);
document.getElementById('statTotalCalls').textContent =
(stats.total_calls_today || 0).toLocaleString();
document.getElementById('statRetransmittedCalls').textContent =
(stats.retransmitted_calls || 0).toLocaleString();
document.getElementById('statTotalDuration').textContent =
formatDuration(stats.total_duration_today || 0);
}
function updateRepeaters(repeaters) {
// Accept either an array or an object map for repeaters
const repeaterList = Array.isArray(repeaters) ? repeaters : Object.values(repeaters || {});
const connected = repeaterList.filter(r => r.status === 'connected');
// Group by connection_type
const groups = {
repeater: connected.filter(r => r.connection_type === 'repeater'),
hotspot: connected.filter(r => r.connection_type === 'hotspot'),
network: connected.filter(r => r.connection_type === 'network'),
unknown: connected.filter(r => !r.connection_type || r.connection_type === 'unknown')
};
// Update total count in stats (inbound + connected outbound)
const outboundList = Array.isArray(state.outbounds) ? state.outbounds : Object.values(state.outbounds || {});
const outboundConnected = outboundList.filter(o => o.status === 'connected').length;
document.getElementById('statConnections').textContent = connected.length + outboundConnected;
// Render each section
renderConnectionGroup('repeaters', 'repeaterCount', groups.repeater, '📶');
renderConnectionGroup('hotspots', 'hotspotCount', groups.hotspot, '📱');
renderConnectionGroup('networks', 'networkCount', groups.network, '🔗');
renderConnectionGroup('unknown', 'unknownCount', groups.unknown, '❓');
}
// Build per-RF-slot display arrays from net-side subscription + translations.
// Subscriptions and translations on the wire are in network vocabulary; the
// card shows the RF-side (what the radio user sees). Each TGID in the result
// has {tgid, translated, title}: translated TGs are colored gold and carry a
// hover tooltip pointing back to the net-side value.
function rfTgLists(netSlot1, netSlot2, translations) {
const netToRf = new Map();
(translations || []).forEach(t => {
// [rf_slot, rf_tgid, net_slot, net_tgid]
netToRf.set(`${t[2]}.${t[3]}`, { rf_slot: t[0], rf_tgid: t[1] });
});
const rf1 = [], rf2 = [];
function bucket(netSlot, tgs) {
if (!Array.isArray(tgs)) return;
tgs.forEach(tg => {
const xlate = netToRf.get(`${netSlot}.${tg}`);
if (xlate) {
const entry = {
tgid: xlate.rf_tgid,
translated: true,
title: `Net TS${netSlot}/TG${tg}`
};
(xlate.rf_slot === 1 ? rf1 : rf2).push(entry);
} else {
const entry = { tgid: tg, translated: false, title: '' };
(netSlot === 1 ? rf1 : rf2).push(entry);
}
});
}
bucket(1, netSlot1);
bucket(2, netSlot2);
return { rf1, rf2 };
}
function renderTgChips(entries) {
return entries.map(e => e.translated
? `<span class="translated-tg" title="${e.title}">${e.tgid}</span>`
: `${e.tgid}`
).join(', ');
}
function renderConnectionGroup(sectionName, countId, connections, icon) {
const section = document.getElementById(`${sectionName}Section`);
const container = document.getElementById(`${sectionName}Container`);
const countElement = document.getElementById(countId);
// Show/hide section based on whether there are connections
if (connections.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
if (countElement) {
countElement.textContent = `(${connections.length})`;
}
// Get streams data to show on slots
const streams = state.streams || [];
container.innerHTML = connections.map(r => {
// Find active streams for this repeater (only active or hang_time, not ended)
const slot1Stream = streams.find(s =>
s.repeater_id == r.repeater_id &&
s.slot == 1 &&
(s.status === 'active' || s.status === 'hang_time')
);
const slot2Stream = streams.find(s =>
s.repeater_id == r.repeater_id &&
s.slot == 2 &&
(s.status === 'active' || s.status === 'hang_time')
);
// Calculate initial connection duration
const connectedDuration = r.connected_at ? formatDuration(Date.now() / 1000 - r.connected_at) : '—';
const hasMissedPings = r.missed_pings > 0;
const headerClass = hasMissedPings ? 'repeater-header-warning' : 'repeater-header';
const hasTranslations = Array.isArray(r.translations) && r.translations.length > 0;
const xlateTitle = hasTranslations ? `${r.translations.length} translation rule(s) active — translated TGs are shown in gold` : '';
const xlateBadge = hasTranslations ? `<span title="${xlateTitle}">🔀</span> ` : '';
const { rf1, rf2 } = rfTgLists(r.slot1_talkgroups, r.slot2_talkgroups, r.translations);
return `
<div class="repeater-card" data-repeater-id="${r.repeater_id}">
<div class="repeater-info">
<div class="${headerClass}" onclick="showRepeaterDetails(${r.repeater_id})" title="Click for detailed information">
${icon} ${xlateBadge}${r.callsign || 'Unknown'} (${r.repeater_id})
</div>
<div class="repeater-meta">
<!-- Row 1: Location / TX / TS1 -->
<div class="repeater-meta-item">
${r.location || 'Location unknown'}
</div>
${r.tx_freq ? `
<div class="repeater-meta-item">
<span class="repeater-meta-label">TX:</span>
<span>${(parseFloat(r.tx_freq) / 1000000).toFixed(4).padStart(8, '0')} MHz</span>
</div>
` : '<div></div>'}
${r.slot1_talkgroups === null ? `
<div class="repeater-meta-item" title="All talkgroups allowed (no configuration)">
<span class="repeater-meta-label">TS1 TGs:</span>
<span>All</span>
</div>
` : Array.isArray(r.slot1_talkgroups) && r.slot1_talkgroups.length === 0 && rf1.length === 0 ? `
<div class="repeater-meta-item" title="All talkgroups denied (empty configuration)">
<span class="repeater-meta-label">TS1 TGs:</span>
<span>None</span>
</div>
` : rf1.length > 0 ? `
<div class="repeater-meta-item" ${r.rpto_received ? 'title="TGs supplied by repeater via RPTO"' : ''}>
<span class="repeater-meta-label ${r.rpto_received ? 'rpto-tgs' : ''}">TS1 TGs:</span>
<span>${renderTgChips(rf1)}</span>
</div>
` : `
<div class="repeater-meta-item">
<span class="repeater-meta-label">TS1 TGs:</span>
<span>None</span>
</div>
`}
<!-- Row 2: Connected / RX / TS2 -->
<div class="repeater-meta-item">
<span class="repeater-meta-label">Connected:</span>
<span data-connected-duration data-connected-at="${r.connected_at || ''}">${connectedDuration}</span>
</div>
${r.rx_freq ? `
<div class="repeater-meta-item">
<span class="repeater-meta-label">RX:</span>
<span>${(parseFloat(r.rx_freq) / 1000000).toFixed(4).padStart(8, '0')} MHz</span>
</div>
` : '<div></div>'}
${r.slot2_talkgroups === null ? `
<div class="repeater-meta-item" title="All talkgroups allowed (no configuration)">
<span class="repeater-meta-label">TS2 TGs:</span>
<span>All</span>
</div>
` : Array.isArray(r.slot2_talkgroups) && r.slot2_talkgroups.length === 0 && rf2.length === 0 ? `
<div class="repeater-meta-item" title="All talkgroups denied (empty configuration)">
<span class="repeater-meta-label">TS2 TGs:</span>
<span>None</span>
</div>
` : rf2.length > 0 ? `
<div class="repeater-meta-item" ${r.rpto_received ? 'title="TGs supplied by repeater via RPTO"' : ''}>
<span class="repeater-meta-label ${r.rpto_received ? 'rpto-tgs' : ''}">TS2 TGs:</span>
<span>${renderTgChips(rf2)}</span>
</div>
` : `
<div class="repeater-meta-item">
<span class="repeater-meta-label">TS2 TGs:</span>
<span>None</span>
</div>
`}
</div>
</div>
<div class="slots-container">
${renderSlot(1, slot1Stream)}
${renderSlot(2, slot2Stream)}
</div>
</div>
`;
}).join('');
}
function renderSlot(slotNum, stream) {
// Determine status and direction
let status = 'idle';
let statusLabel = 'IDLE';
if (stream) {
// Check if this is a TX (forwarded) stream or RX (received) stream
if (stream.is_assumed) {
status = 'transmitting';
statusLabel = '⬆️ TX';
} else {
status = 'receiving';
statusLabel = '⬇️ RX';
}
if (stream.status === 'hang_time') {
status = 'hang_time';
statusLabel = 'HANG';
}
// Data calls override the RX/HANG pill with a gold DATA pill.
// Server stream state ends ~200ms after a single burst; the
// 3s persistence-of-vision hold is enforced client-side via
// DATA_PERSIST_MS in the stream_start handler.
if (stream.is_data) {
status = 'data';
statusLabel = 'DATA';
}
}
// Compose a human Type label from the two wire dimensions:
// call_type ('group' | 'private') — addressing
// is_data (bool, payload kind) — voice vs data
function formatCallType(s) {
if (!s) return '—';
const addr = s.call_type === 'private' ? 'Private' : 'Group';
const kind = s.is_data ? 'Data' : 'Voice';
return `${addr} ${kind}`;
}
// Special rendering for hang time: show only TG and call type
if (status === 'hang_time') {
const destination = stream?.dst_id || '—';
const callType = formatCallType(stream);
return `
<div class="slot ${status}">
<div class="slot-col">
<div class="slot-col-label">Slot ${slotNum}</div>
<div class="slot-status-badge ${status}">${statusLabel}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Source</div>
<div class="slot-col-value empty">—</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Destination</div>
<div class="slot-col-value highlight">${destination}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Type</div>
<div class="slot-col-value">${callType}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Duration</div>
<div class="slot-col-value empty">—</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Callsign</div>
<div class="slot-col-value empty">—</div>
</div>
</div>
`;
}
// Prepare field values
const source = stream?.src_id || '—';
const destination = stream?.dst_id || '—';
const callType = formatCallType(stream);
// Calculate real-time duration for active streams
let duration = '—';
if (stream) {
if (stream.status === 'active' && stream.start_time) {
// For active streams, calculate elapsed time from start_time
const elapsed = Date.now() / 1000 - stream.start_time;
// Only show if elapsed time is reasonable (not negative, not more than 1 hour)
if (elapsed >= 0 && elapsed < 3600) {
duration = Math.round(elapsed) + 's';
} else {
duration = '0s'; // Reset if time is invalid
}
} else if (stream.duration !== undefined) {
// For ended/hang_time streams, use the final duration
duration = Math.round(stream.duration) + 's';
}
}
const packets = stream?.packets || 0;
// Get callsign directly from stream data (looked up by server)
const callsign = stream?.callsign || '—';
return `
<div class="slot ${status}">
<div class="slot-col">
<div class="slot-col-label">Slot ${slotNum}</div>
<div class="slot-status-badge ${status}">${statusLabel}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Source</div>
<div class="slot-col-value ${stream ? 'highlight' : 'empty'}">${source}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Destination</div>
<div class="slot-col-value ${stream ? 'highlight' : 'empty'}">${destination}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Type</div>
<div class="slot-col-value ${stream ? '' : 'empty'}">${callType}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Duration</div>
<div class="slot-col-value ${stream ? '' : 'empty'}"
data-stream-duration
data-start-time="${stream?.start_time || ''}"
data-status="${stream?.status || ''}"
data-final-duration="${stream?.duration || ''}">${duration}</div>
</div>
<div class="slot-col">
<div class="slot-col-label">Callsign</div>
<div class="slot-col-value ${stream && callsign !== '—' ? '' : 'empty'}">${callsign}</div>
</div>
</div>
`;
}
function updateOpenbridges(openbridges) {
const container = document.getElementById('openbridgesContainer');
const countElement = document.getElementById('openbridgeCount');
const section = document.getElementById('openbridgeSection');
const list = Array.isArray(openbridges) ? openbridges : Object.values(openbridges || {});
if (list.length === 0) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = 'block';
const upCount = list.filter(o => o.status === 'connected').length;
if (countElement) countElement.textContent = upCount > 0 ? `(${upCount})` : '';
const streams = state.streams || [];
container.innerHTML = list.map(o => {
const up = o.status === 'connected';
const headerClass = up ? 'outbound-header-connected' : 'outbound-header-disconnected';
const upDur = (up && o.connected_at) ? formatDuration(Date.now() / 1000 - o.connected_at) : '—';
// Talkgroup ownership map { "tgid": ts } -> "TS1/31, TS2/3120 …"
// (display as TS/TGID — standard DMR nomenclature; config keys by TGID
// because the table's job is to assign each TGID a TS.)
const tgs = o.talkgroups || {};
const tgList = Object.keys(tgs).sort((a, b) => Number(a) - Number(b))
.map(t => `TS${tgs[t]}/${t}`).join(', ') || '—';
// Live streams on this trunk (OBP is stream-multiplexed — list them all)
const active = streams.filter(s =>
s.connection_type === 'openbridge' &&
s.connection_name === o.connection_name &&
(s.status === 'active' || s.status === 'hang_time'));
const streamHtml = active.length
? `<div class="obp-streams">` + active.map(s => {
const who = s.callsign ? `${s.callsign} (${s.src_id})` : s.src_id;
// TX (assumed/forwarded) vs RX (received from the peer),
// with a DATA override. OBP is stream-multiplexed with no
// slot to protect and no hang time, so there is no HANG
// state here (unlike renderSlot()).
let badge = s.is_assumed ? 'transmitting' : 'receiving';
let label = s.is_assumed ? '⬆️ TX' : '⬇️ RX';
if (s.is_data) { badge = 'data'; label = 'DATA'; }
return `<span class="obp-stream-pill">`
+ `<span class="slot-status-badge ${badge}">${label}</span> `
+ `${who} → <span class="slot-col-value highlight">TG${s.dst_id}</span> `
+ `<span style="color:#64748b">TS${s.slot}</span></span>`;
}).join('') + `</div>`
: `<div class="obp-streams"><span class="obp-stream-pill idle">— idle —</span></div>`;
return `
<div class="repeater-card" style="flex-direction:column;align-items:stretch">
<div class="repeater-info" style="min-width:100%">
<div class="${headerClass}">↔️ ${o.connection_name}
<span style="font-weight:400;color:#94a3b8">(NetID ${o.network_id})</span></div>
<div style="color:#94a3b8;font-size:11px;margin:2px 0">
${o.remote_address}:${o.remote_port} · up ${upDur} ·
${o.preserve_source_peer ? 'preserves source peer' : 'network_id in RptrId'}</div>
<div style="font-size:10px;margin-top:4px">
<span style="color:#94a3b8">TGs:</span> ${tgList}</div>
${streamHtml}
</div>
</div>`;
}).join('');
}
function updateOutbounds(outbounds) {
const container = document.getElementById('outboundsContainer');
const countElement = document.getElementById('outboundCount');
const outboundSection = document.getElementById('outboundSection');
// outbounds is always an array in state
const outboundList = outbounds || [];
// Hide entire section if no outbound connections configured
if (outboundList.length === 0) {
if (outboundSection) {
outboundSection.style.display = 'none';
}
return;
}
// Show section if there are outbound connections
if (outboundSection) {
outboundSection.style.display = 'block';
}
// Update the count in the header
const connectedCount = outboundList.filter(o => o.status === 'connected').length;
if (countElement) {
countElement.textContent = connectedCount > 0 ? `(${connectedCount})` : '';
}
// Get streams data to show on slots
const streams = state.streams || [];
container.innerHTML = outboundList.map(o => {
// Color coding per design doc - same as repeater headers (colored text, no background)
// - Cyan text: Connected and healthy
// - Yellow text: Connected but missed ACKs
// - Dark gray text: Disconnected or connecting
let headerClass = 'outbound-header-connected';
if (o.status === 'connected') {
if (o.missed_acks > 0) {
headerClass = 'outbound-header-warning'; // Yellow text for missed ACKs
} else {
headerClass = 'outbound-header-connected'; // Cyan text
}
} else if (o.status === 'connecting' || o.status === 'disconnected') {
headerClass = 'outbound-header-disconnected'; // Dark gray text
}
const connectedDuration = (o.status === 'connected' && o.connected_at) ?
formatDuration(Date.now() / 1000 - o.connected_at) : '—';
// Status text for the first row
let statusText = '';
if (o.status === 'connecting') {
statusText = 'Connecting...';
} else if (o.status === 'disconnected' && o.disconnect_reason) {
statusText = o.disconnect_reason;
} else if (o.status === 'error' && o.error_message) {
statusText = o.error_message;
} else if (o.status === 'connected') {
statusText = `${o.remote_address}:${o.remote_port}`;
} else {
statusText = `${o.remote_address}:${o.remote_port}`;
}
// Find active streams for this outbound connection (by connection_name.slot key)
const slot1Stream = streams.find(s =>
(s.connection_type === 'outbound' || (s.connection_name && s.connection_name === o.connection_name)) &&
s.connection_name === o.connection_name &&
Number(s.slot) === 1 &&
(s.status === 'active' || s.status === 'hang_time')
) || null;
const slot2Stream = streams.find(s =>
(s.connection_type === 'outbound' || (s.connection_name && s.connection_name === o.connection_name)) &&
s.connection_name === o.connection_name &&
Number(s.slot) === 2 &&
(s.status === 'active' || s.status === 'hang_time')
) || null;
return `
<div class="repeater-card" data-outbound-name="${o.connection_name}">
<div class="repeater-info">
<div class="${headerClass}">
🔗 ${o.connection_name} (${o.radio_id})
</div>
<div class="repeater-meta">
<!-- Row 1: Status/Server / (empty) / TS1 -->
<div class="repeater-meta-item" ${o.status === 'error' ? 'style="color: #f87171;"' : ''}>
${statusText}
</div>
<div></div>
${o.slot1_talkgroups === null ? `
<div class="repeater-meta-item" title="All talkgroups allowed">
<span class="repeater-meta-label">TS1 TGs:</span>
<span>All</span>
</div>
` : Array.isArray(o.slot1_talkgroups) && o.slot1_talkgroups.length === 0 ? `
<div class="repeater-meta-item" title="All talkgroups denied">
<span class="repeater-meta-label">TS1 TGs:</span>
<span>None</span>
</div>
` : Array.isArray(o.slot1_talkgroups) && o.slot1_talkgroups.length > 0 ? `
<div class="repeater-meta-item">
<span class="repeater-meta-label">TS1 TGs:</span>
<span>${o.slot1_talkgroups.join(', ')}</span>
</div>
` : '<div></div>'}
<!-- Row 2: Connected / (empty) / TS2 -->
<div class="repeater-meta-item">
<span class="repeater-meta-label">Connected:</span>
<span data-connected-duration data-connected-at="${o.connected_at || ''}">${connectedDuration}</span>
</div>
<div></div>
${o.slot2_talkgroups === null ? `
<div class="repeater-meta-item" title="All talkgroups allowed">
<span class="repeater-meta-label">TS2 TGs:</span>
<span>All</span>
</div>
` : Array.isArray(o.slot2_talkgroups) && o.slot2_talkgroups.length === 0 ? `
<div class="repeater-meta-item" title="All talkgroups denied">
<span class="repeater-meta-label">TS2 TGs:</span>
<span>None</span>
</div>
` : Array.isArray(o.slot2_talkgroups) && o.slot2_talkgroups.length > 0 ? `
<div class="repeater-meta-item">
<span class="repeater-meta-label">TS2 TGs:</span>
<span>${o.slot2_talkgroups.join(', ')}</span>
</div>
` : '<div></div>'}
</div>
</div>
<div class="slots-container">
${renderSlot(1, slot1Stream)}
${renderSlot(2, slot2Stream)}
</div>
</div>
`;
}).join('');
}
function updateStreams(streams) {
// Streams are now displayed inline with repeaters
// Just trigger a repeater update to refresh the display
state.streams = Array.isArray(streams) ? streams : [];
if (state.repeaters) {
updateRepeaters(state.repeaters);
}
if (state.outbounds) {
const outboundList = Array.isArray(state.outbounds) ? state.outbounds : Object.values(state.outbounds || {});
updateOutbounds(outboundList);
updateOpenbridges(state.openbridges);
}
}
function updateEvents(events) {
const log = document.getElementById('eventsLog');
if (events.length === 0) {
log.innerHTML = '<div class="empty-state">No events yet</div>';
return;
}
log.innerHTML = events.slice(-20).reverse().map(e => {
const time = new Date(e.timestamp * 1000).toLocaleTimeString();
const dataStr = formatEventData(e.type, e.data);
return `
<div class="event">
<div class="time">${time}</div>
<span class="type">${e.type}</span>
<span>${dataStr}</span>
</div>
`;
}).join('');
}
function updateUserDbStatus(status) {
const el = document.getElementById('userDbStatus');
if (!el) return;
if (!status) { el.textContent = ''; el.title = ''; return; }
const rows = status.loaded_rows || 0;
const ts = status.refresh_timestamp || 0;
const srcStatus = status.source_status || 'unknown';
let ageStr;
if (!ts) {
ageStr = 'never refreshed';
} else {
const elapsed = Math.floor(Date.now() / 1000 - ts);
if (elapsed < 60) ageStr = `${elapsed}s ago`;
else if (elapsed < 3600) ageStr = `${Math.floor(elapsed / 60)}m ago`;
else if (elapsed < 86400) ageStr = `${Math.floor(elapsed / 3600)}h ago`;
else ageStr = `${Math.floor(elapsed / 86400)}d ago`;
}
let color = '#9ca3af';
if (srcStatus === 'error') color = '#fbbf24'; // gold, matches other warnings
else if (srcStatus === 'not_modified' || srcStatus === 'ok') color = '#9ca3af';
el.style.color = color;
el.textContent = `Callsign DB: ${rows.toLocaleString()} entries, updated ${ageStr}`;
// Verbose hover so operators can diagnose without digging into logs.
const parts = [
`Source: ${status.source_url || '(unset)'}`,
`Status: ${srcStatus}`,
`Rows: ${rows.toLocaleString()}`
];
if (status.last_modified_header) parts.push(`Upstream Last-Modified: ${status.last_modified_header}`);
if (status.last_error) parts.push(`Last error: ${status.last_error}`);
el.title = parts.join('\n');
}
// Re-tick the age string every 60s so it stays fresh between pushes.
setInterval(() => {
if (state.user_db_status) updateUserDbStatus(state.user_db_status);
}, 60000);
// ---- Collapsible sections -------------------------------------------
// Every collapsible header carries data-section (a stable key). Normal
// sections also carry data-collapse-target (the id of the body to hide).
// Last Heard is special: collapsing it does NOT hide the body — it
// filters the table to currently-active calls and retitles the header
// "Active Streams". Collapse state persists in localStorage.
const COLLAPSE_KEY = 'hblink4-collapsed-sections';
// Default collapse state for sections the user hasn't explicitly toggled.
// Everything starts open except Recent Events, which starts collapsed.
const COLLAPSE_DEFAULTS = { events: true };
let collapsedSections = {};
try {
collapsedSections = JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
} catch (e) {
collapsedSections = {};
}
function isSectionCollapsed(key) {
// A stored preference wins; otherwise fall back to the default.
return key in collapsedSections
? !!collapsedSections[key]
: !!COLLAPSE_DEFAULTS[key];
}
function applySectionCollapse(key) {
const collapsed = isSectionCollapsed(key);
const header = document.querySelector(`.collapsible[data-section="${key}"]`);
if (header) header.classList.toggle('collapsed', collapsed);
if (key === 'lastHeard') {
// Special case: retitle + filter rather than hide the body.
const title = document.getElementById('lastHeardTitle');
if (title) title.textContent = collapsed ? 'Active Streams' : 'Last Heard';
renderLastHeard();
return;
}
const targetId = header && header.dataset.collapseTarget;
const body = targetId ? document.getElementById(targetId) : null;
if (body) body.style.display = collapsed ? 'none' : '';
}
function toggleSectionCollapse(key) {
collapsedSections[key] = !isSectionCollapsed(key);
try {
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsedSections));
} catch (e) { /* ignore storage failures */ }
applySectionCollapse(key);
}
function initCollapsibles() {
document.querySelectorAll('.collapsible[data-section]').forEach(header => {
const key = header.dataset.section;
header.addEventListener('click', () => toggleSectionCollapse(key));
applySectionCollapse(key);
});
}
function updateLastHeard(users) {
// Persist the full list; renderLastHeard decides what to show based
// on whether the section is collapsed (Active Streams) or not.
state.last_heard = Array.isArray(users) ? users : [];
renderLastHeard();
}
function renderLastHeard() {
const tbody = document.getElementById('lastHeardTable');
if (!tbody) return;
const activeOnly = isSectionCollapsed('lastHeard');
let users = Array.isArray(state.last_heard) ? state.last_heard : [];
if (activeOnly) users = users.filter(u => u.active);
if (!users || users.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" style="padding: 8px 20px; text-align: center; color: #9ca3af;">
${activeOnly ? 'No active streams' : 'No users heard yet'}
</td>
</tr>
`;
return;
}
// Build id-keyed lookup for translation check (state.repeaters is an array).
const repeaterList = Array.isArray(state.repeaters) ? state.repeaters : Object.values(state.repeaters || {});
const repeaterById = {};
repeaterList.forEach(r => { if (r && r.repeater_id != null) repeaterById[r.repeater_id] = r; });
// Dashboard maintains its own list of last 10 users
tbody.innerHTML = users.map(user => {
const lastHeardDate = new Date(user.last_heard * 1000);
const now = Date.now();
const elapsed = Math.floor((now - lastHeardDate) / 1000);
let timeDisplay;
if (user.active) {
// Stream is currently active - show badge
timeDisplay = '<span style="background: #10b981; color: white; padding: 3px 8px; border-radius: 4px; font-size: 9px; font-weight: 600;">Active</span>';
} else if (elapsed < 60) {
timeDisplay = `${elapsed}s ago`;
} else if (elapsed < 3600) {
timeDisplay = `${Math.floor(elapsed / 60)}m ago`;
} else {
// For times over an hour, check if it's today
const today = new Date();
const isToday = lastHeardDate.toDateString() === today.toDateString();
if (isToday) {
// Same day - show time only
timeDisplay = lastHeardDate.toLocaleTimeString();
} else {
// Different day - show date and time
const options = {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
};
timeDisplay = lastHeardDate.toLocaleDateString('en-US', options);
}
}
const callsign = user.callsign || '-';
// Use source_name if available, fall back to repeater_id for backward compatibility.
// Flag only if THIS specific transmission's (slot, talkgroup) matched a translation rule
// on the source repeater — not just "source has any rules".
const srcRepeater = user.source_repeater_id != null ? repeaterById[user.source_repeater_id] : null;
const xlate = srcRepeater && Array.isArray(srcRepeater.translations)
? srcRepeater.translations.find(t => t[0] === user.slot && t[1] === user.talkgroup)
: null;
const srcXlateEmoji = xlate
? ` <span title="Translated: RF TS${xlate[0]}/TG${xlate[1]} ↔ Net TS${xlate[2]}/TG${xlate[3]}">🔀</span>`
: '';
const source = (user.source_name || user.repeater_id || '-') + srcXlateEmoji;
// Destination rendering composes two dimensions so both are
// visible at a glance:
// addressing: 👥 group / 👤 unit (private)
// payload: 🎤 voice / 📟 data (not forwarded)
// 📟 (pager) carries the strongest semantic tie to what DMR
// data actually is — short text/GPS bursts over radio.
const isUnit = user.call_type === 'private';
const isData = !!user.is_data;
const addrEmoji = isUnit
? '<span title="Unit (private) call">👤</span>'
: '<span title="Group call">👥</span>';
const payloadEmoji = isData
? '<span title="Data call (not forwarded)">📟</span>'
: '<span title="Voice call">🎤</span>';
const destination = `${addrEmoji}${payloadEmoji} ${user.talkgroup}`;
return `
<tr style="border-bottom: 1px solid #374151;">
<td style="padding: 8px;">${user.radio_id}</td>
<td style="padding: 8px; font-weight: 500; color: ${user.callsign ? 'white' : '#6b7280'};">${callsign}</td>
<td style="padding: 8px;">${source}</td>
<td style="padding: 8px; text-align: center;">${user.slot}</td>
<td style="padding: 8px;">${destination}</td>
<td style="padding: 8px; color: ${user.active ? '' : '#9ca3af'};">${timeDisplay}</td>
</tr>
`;
}).join('');
}
function processEventUpdate(event) {
// Update last_heard if included in event
if (event.last_heard) {
state.last_heard = event.last_heard;
updateLastHeard(state.last_heard);
}
// Update local state immediately based on event type
const eventType = event.type;
const data = event.data;
switch(eventType) {
case 'stream_start':
// Add or replace stream in state immediately with fresh start_time
// Handle both repeater and outbound streams
const connection_type = data.connection_type || 'repeater';
let startStreamIndex;
if (connection_type === 'outbound') {
startStreamIndex = state.streams.findIndex(s =>
s.connection_type === 'outbound' &&
s.connection_name === data.connection_name &&
s.slot === data.slot
);
} else if (connection_type === 'openbridge') {
// OBP is stream-multiplexed: identify by stream_id, not
// slot (many concurrent streams can share a slot).
startStreamIndex = state.streams.findIndex(s =>
s.connection_type === 'openbridge' &&
s.connection_name === data.connection_name &&
s.stream_id === data.stream_id
);
} else {
startStreamIndex = state.streams.findIndex(s =>
s.repeater_id == data.repeater_id && s.slot == data.slot
);
}
const newStream = {
...data,
status: 'active',
start_time: event.timestamp // Use fresh timestamp from event
};
if (startStreamIndex >= 0) {
state.streams[startStreamIndex] = newStream;
} else {
state.streams.push(newStream);
}
// Data calls: start/refresh the 3s persistence timer so
// the gold DATA pill stays visible long enough to read.
// Voice calls: clear any lingering data timer for this
// slot (a real voice transmission supersedes).
{
const persistConnId = (connection_type === 'outbound' || connection_type === 'openbridge')
? data.connection_name : data.repeater_id;
// OBP is stream-multiplexed — scope the persist timer to the
// stream_id, not the slot (multiple streams share a slot).
const persistScope = connection_type === 'openbridge' ? data.stream_id : data.slot;
const persistKey = dataPersistKey(connection_type, persistConnId, persistScope);
if (data.is_data) {
scheduleDataPersistDrop(persistKey, connection_type, (s) => {
if (connection_type === 'outbound') {
return s.connection_type === 'outbound'
&& s.connection_name === data.connection_name
&& Number(s.slot) === Number(data.slot);
}
if (connection_type === 'openbridge') {
return s.connection_type === 'openbridge'
&& s.connection_name === data.connection_name
&& s.stream_id === data.stream_id;
}
return s.repeater_id == data.repeater_id
&& Number(s.slot) === Number(data.slot);
});
} else {
clearDataPersist(persistKey);
}
}
// Immediately update the display
if (connection_type === 'outbound') {
updateOutbounds(state.outbounds);
updateOpenbridges(state.openbridges);
} else if (connection_type === 'openbridge') {
updateOpenbridges(state.openbridges);
} else if (state.repeaters) {
updateRepeaters(state.repeaters);
}
// Update last heard display
updateLastHeard(state.last_heard);
break;
case 'stream_end':
// Update stream status to hang_time
const endConnectionType = data.connection_type || 'repeater';
if (endConnectionType === 'openbridge') {
// OBP has no hang time — drop the ended stream outright so
// the pill clears immediately (matches server.py, which
// deletes OBP streams on end). Multiplexed: match by
// stream_id. Data pills are governed by the persistence
// timer; leave those for scheduleDataPersistDrop to remove.
const endObp = state.streams.find(s =>
s.connection_type === 'openbridge' &&
s.connection_name === data.connection_name &&
s.stream_id === data.stream_id);
if (endObp && !endObp.is_data) {
state.streams = state.streams.filter(s =>
!(s.connection_type === 'openbridge' &&
s.connection_name === data.connection_name &&
s.stream_id === data.stream_id));
}
updateOpenbridges(state.openbridges);
break;
}
const endStreamIndex = state.streams.findIndex(s => {
if (endConnectionType === 'outbound') {
return s.connection_type === 'outbound' &&
s.connection_name === data.connection_name &&
Number(s.slot) === Number(data.slot);
}
return s.repeater_id == data.repeater_id && Number(s.slot) === Number(data.slot);
});
if (endStreamIndex >= 0) {
const existingStream = state.streams[endStreamIndex];
// Data streams: do not transition to hang_time. The
// persistence timer controls DATA pill lifetime; a
// mid-persistence hang_time flip would just cause a
// hang-time rendering to flash before the timer drops
// the stream. Let the timer handle removal.
if (!existingStream.is_data) {
existingStream.status = 'hang_time';
existingStream.hang_time = data.hang_time !== undefined ? data.hang_time : 0;
const packetCount = (data.packet_count !== undefined) ? data.packet_count :
(data.packets !== undefined ? data.packets : (existingStream.packets !== undefined ? existingStream.packets : 0));
existingStream.packets = packetCount;
existingStream.duration = data.duration;
existingStream.end_reason = data.end_reason || data.reason || existingStream.end_reason || 'unknown';
}
}
// Immediately update the display
if (endConnectionType === 'outbound') {
const outboundList = Array.isArray(state.outbounds) ? state.outbounds : Object.values(state.outbounds || {});
updateOutbounds(outboundList);
updateOpenbridges(state.openbridges);
} else if (state.repeaters) {
updateRepeaters(state.repeaters);
// Update last heard display for repeater RX streams
updateLastHeard(state.last_heard);
}
break;
case 'hang_time_expired':
// Remove the stream from state
const hangConnectionType = data.connection_type || 'repeater';
state.streams = state.streams.filter(s => {
if (hangConnectionType === 'outbound') {
return !(s.connection_type === 'outbound' &&
s.connection_name === data.connection_name &&
Number(s.slot) === Number(data.slot));
}
return !(s.repeater_id == data.repeater_id && Number(s.slot) === Number(data.slot));
});
// Immediately update the display
if (hangConnectionType === 'outbound') {
const outboundList = Array.isArray(state.outbounds) ? state.outbounds : Object.values(state.outbounds || {});
updateOutbounds(outboundList);
updateOpenbridges(state.openbridges);
} else if (state.repeaters) {
updateRepeaters(state.repeaters);
// Also update last heard display for repeater RX streams
updateLastHeard(state.last_heard);
}
break;
case 'repeater_connected':
// Update repeater info (especially missed_pings for yellow warning indicator)
if (state.repeaters && data.repeater_id) {
const repeaterIndex = state.repeaters.findIndex(r => r.repeater_id == data.repeater_id);
if (repeaterIndex >= 0) {
const oldMissedPings = state.repeaters[repeaterIndex].missed_pings || 0;
const newMissedPings = data.missed_pings !== undefined ? data.missed_pings : 0;
// Update the repeater data while preserving other fields
state.repeaters[repeaterIndex] = {
...state.repeaters[repeaterIndex],
...data,
last_ping: data.last_ping || state.repeaters[repeaterIndex].last_ping,
missed_pings: newMissedPings
};
// Play appropriate tone based on missed_pings change
if (oldMissedPings === 0 && newMissedPings > 0) {
// Repeater just missed a ping (blue -> yellow)
playAlertTone();
} else if (oldMissedPings > 0 && newMissedPings === 0) {
// Ping restored (yellow -> blue)
playConnectTone();
}
// Immediately refresh the display to update yellow warning
updateRepeaters(state.repeaters);
} else {
// New repeater connection
playConnectTone();
}
}
break;
case 'repeater_details':
// Store detailed repeater information (sent once on connection)
if (data.repeater_id) {
state.repeater_details[data.repeater_id] = data;
}
break;
case 'repeater_disconnected':
// Remove repeater from state immediately
if (state.repeaters && state.repeaters[data.repeater_id]) {
playDisconnectTone();
delete state.repeaters[data.repeater_id];
// Remove from DOM immediately
const repeaterCard = document.querySelector(`[data-repeater-id="${data.repeater_id}"]`);
if (repeaterCard) {
repeaterCard.remove();
}
// Also clean up any streams from this repeater
state.streams = state.streams.filter(s => s.repeater_id != data.repeater_id);
// Update stats
updateStats(state.stats, state.repeaters, state.streams);
}
break;
case 'outbound_connected':
// Add/update outbound connection
const outboundIdx = state.outbounds.findIndex(o => o.connection_name === data.connection_name);
if (outboundIdx >= 0) {
state.outbounds[outboundIdx] = { ...data, status: 'connected' };
} else {
state.outbounds.push({ ...data, status: 'connected' });
}
updateOutbounds(state.outbounds);
updateOpenbridges(state.openbridges);
playConnectTone();
break;
case 'outbound_connecting':
// Outbound connection is attempting to connect
const connectingIdx = state.outbounds.findIndex(o => o.connection_name === data.connection_name);
if (connectingIdx >= 0) {
state.outbounds[connectingIdx] = { ...data, status: 'connecting' };
} else {
state.outbounds.push({ ...data, status: 'connecting' });
}
updateOutbounds(state.outbounds);
updateOpenbridges(state.openbridges);
break;
case 'outbound_disconnected':
// Update outbound connection to disconnected (don't remove)
const disconnectIdx = state.outbounds.findIndex(o => o.connection_name === data.connection_name);
if (disconnectIdx >= 0) {
state.outbounds[disconnectIdx] = { ...state.outbounds[disconnectIdx], ...data, status: 'disconnected' };
updateOutbounds(state.outbounds);
updateOpenbridges(state.openbridges);
playDisconnectTone();
}
break;
case 'outbound_error':
// Update outbound connection error status
const errorIdx = state.outbounds.findIndex(o => o.connection_name === data.connection_name);
if (errorIdx >= 0) {
state.outbounds[errorIdx] = { ...state.outbounds[errorIdx], ...data, status: 'error' };
} else {
state.outbounds.push({ ...data, status: 'error' });
}
updateOutbounds(state.outbounds);
updateOpenbridges(state.openbridges);
playAlertTone();
break;
case 'openbridge_connected':
// OBP trunk is up. There is no handshake to observe — "up"
// means the socket is bound and the trunk is configured, so
// this event is also re-sent on every sync_request. Treat it
// as idempotent (upsert, no tone) rather than an arrival.
// connected_at comes from the event envelope, matching what
// the server stamps on its own copy, so the card can show uptime.
{
const obpIdx = state.openbridges.findIndex(o => o.connection_name === data.connection_name);
if (obpIdx >= 0) {
state.openbridges[obpIdx] = {
...state.openbridges[obpIdx], ...data,
status: 'connected',
connected_at: state.openbridges[obpIdx].connected_at || event.timestamp,
last_activity: event.timestamp
};
} else {
state.openbridges.push({
...data,
status: 'connected',
connected_at: event.timestamp,
last_activity: event.timestamp
});
}
updateOpenbridges(state.openbridges);
}
break;
case 'openbridge_disconnected':
// Not emitted by the core today (a bound socket has nothing to
// drop), but the server already handles it — wire the browser
// to match so the card follows if that ever changes.
{
const obpDownIdx = state.openbridges.findIndex(o => o.connection_name === data.connection_name);
if (obpDownIdx >= 0) {
state.openbridges[obpDownIdx] = {
...state.openbridges[obpDownIdx], ...data,
status: 'disconnected',
disconnected_at: event.timestamp
};
updateOpenbridges(state.openbridges);
}
}
break;
}
}
function formatEventData(type, data) {
switch(type) {
case 'repeater_connected':
return `Repeater ${data.radio_id} (${data.callsign || 'UNKNOWN'}) connected from ${data.address}`;
case 'repeater_disconnected':
return `Repeater ${data.repeater_id} (${data.callsign || 'UNKNOWN'}) disconnected - ${data.reason || 'unknown reason'}`;
case 'stream_start':
return `Stream started: ${data.src_id} → TG ${data.dst_id} on repeater ${data.repeater_id} slot ${data.slot}`;
case 'stream_update':
return `Stream update: ${data.packets} packets, ${data.duration}s`;
case 'stream_end':
const endReason = data.end_reason || data.reason || 'unknown';
const hangTime = data.hang_time ? `, entering hang time (${data.hang_time}s)` : '';
return `Stream ended (${endReason}): ${data.packets} packets, ${data.duration}s${hangTime}`;
case 'hang_time_expired':
return `Hang time expired: Repeater ${data.repeater_id} slot ${data.slot}`;
default:
return JSON.stringify(data);
}
}
function handleEvent(event) {
// Re-fetch state and update local state object
fetch('/api/repeaters').then(r => r.json()).then(data => {
state.repeaters = data.repeaters;
updateRepeaters(data.repeaters);
});
fetch('/api/streams').then(r => r.json()).then(data => {
state.streams = data.streams;
updateStreams(data.streams);
});
fetch('/api/events?limit=10').then(r => r.json()).then(data => {
state.events = data.events;
updateEvents(data.events);
});
fetch('/api/stats').then(r => r.json()).then(data => {
state.stats = data.stats;
// Compute total connections: inbound repeaters + connected outbounds
const outboundList = Array.isArray(state.outbounds) ? state.outbounds : Object.values(state.outbounds || {});
const outboundConnected = outboundList.filter(o => o.status === 'connected').length;
document.getElementById('statConnections').textContent = data.repeaters_connected + outboundConnected;
document.getElementById('statStreams').textContent = data.active_streams;
document.getElementById('statTotalCalls').textContent = data.stats.total_calls_today.toLocaleString();
document.getElementById('statRetransmittedCalls').textContent = data.stats.retransmitted_calls.toLocaleString();
document.getElementById('statTotalDuration').textContent = formatDuration(data.stats.total_duration_today || 0);
});
}
// Update active stream durations and connection durations every second (without re-rendering everything)
setInterval(() => {
// Update stream durations
document.querySelectorAll('[data-stream-duration]').forEach(el => {
const status = el.dataset.status;
const startTime = parseFloat(el.dataset.startTime);
const finalDuration = parseFloat(el.dataset.finalDuration);
if (status === 'active' && startTime) {
const elapsed = Date.now() / 1000 - startTime;
// Only update if elapsed time is reasonable (between 0 and 10 minutes)
// This prevents showing huge elapsed times when old data hasn't been cleared yet
if (elapsed >= 0 && elapsed < 600) {
el.textContent = Math.round(elapsed) + 's';
}
} else if (finalDuration && status !== 'active') {
// For ended/hang_time streams, show final duration (don't let timer run)
if (!el.textContent.startsWith(Math.round(finalDuration) + 's')) {
el.textContent = Math.round(finalDuration) + 's';
}
}
});
// Update connection durations
document.querySelectorAll('[data-connected-duration]').forEach(el => {
const connectedAt = parseFloat(el.dataset.connectedAt);
if (connectedAt) {
const elapsed = Date.now() / 1000 - connectedAt;
el.textContent = formatDuration(elapsed);
}
});
}, 1000);
// Keep connection alive
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 30000);
// Add click handlers to status badges
document.getElementById('wsStatus').addEventListener('click', () => {
const el = document.getElementById('wsStatus');
if (el.classList.contains('disconnected')) {
// Try to reconnect WebSocket immediately
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (ws) {
ws.close();
}
connectWebSocket();
} else {
// Reload page when connected
location.reload();
}
});
// HBlink4 badge is not clickable - it's just a status indicator
// Users can't control the server-side connection from the browser
// Set up audio toggle button
document.getElementById('audioToggle').addEventListener('click', toggleAudio);
updateAudioToggle();
// ---------------------------------------------------------------------------
// Demo mode (?demo in URL) — coherent static scenario, no WebSocket/server.
// Paradigm (HBlink4 = edge access server fed by an HBlink3 core over OBP):
// • KS-CORE OBP trunk carries US-Nationwide (31/TS1), KS-Statewide
// (3120/TS2) and MO-Local (3100/TS2) to/from the core.
// • N0CAL is transmitting KS-Statewide (3120) IN on the trunk → delivered
// to subscribed devices (W0ABC, KD0XYZ) on TS2.
// • W0ABC keyed US-Nationwide (31) on its TS1 → egresses OUT the trunk.
// • W0DEF just finished MO-Local (3100) on KD0XYZ TS1 → TX hang.
// • N0MJS has an RF translation TS2/TG8 ↔ Net TS2/TG3120 (shown gold).
// The 1s ticker still runs, so active durations count up live.
// ---------------------------------------------------------------------------
function loadDemo() {
const now = Date.now() / 1000; // unix seconds
state.repeaters = [
{ repeater_id: 3112001, callsign: 'W0ABC', location: 'Topeka, KS',
status: 'connected', connection_type: 'repeater',
rx_freq: '448800000', tx_freq: '443800000',
connected_at: now - 3672, missed_pings: 0,
slot1_talkgroups: [31], slot2_talkgroups: [3120, 3100],
translations: [], rpto_received: false },
{ repeater_id: 3112002, callsign: 'KD0XYZ', location: 'Lawrence, KS',
status: 'connected', connection_type: 'hotspot',
rx_freq: '', tx_freq: '',
connected_at: now - 1245, missed_pings: 0,
slot1_talkgroups: [3100], slot2_talkgroups: [3120],
translations: [], rpto_received: true },
{ repeater_id: 3112003, callsign: 'N0MJS', location: 'Wichita, KS',
status: 'connected', connection_type: 'repeater',
rx_freq: '449500000', tx_freq: '444500000',
connected_at: now - 412, missed_pings: 1,
slot1_talkgroups: [31], slot2_talkgroups: [3120],
translations: [[2, 8, 2, 3120]], rpto_received: false },
];
state.outbounds = [
{ connection_name: 'KC-REGIONAL', radio_id: 3129200, status: 'connected',
connected_at: now - 5412, missed_acks: 0,
remote_address: '10.5.0.1', remote_port: 54001,
slot1_talkgroups: [31], slot2_talkgroups: [3100] },
];
state.openbridges = [
{ connection_name: 'KS-CORE', network_id: 3120, status: 'connected',
connected_at: now - 8130,
remote_address: 'core.ksdmr.net', remote_port: 62031,
preserve_source_peer: true,
talkgroups: { '31': 1, '3120': 2, '3100': 2 } },
];
state.streams = [
// Call A: KS-Statewide (3120) inbound on the trunk → devices (TS2)
{ connection_type: 'openbridge', connection_name: 'KS-CORE', slot: 2,
status: 'active', is_assumed: false, is_data: false, call_type: 'group',
src_id: 3188765, callsign: 'N0CAL', dst_id: 3120, start_time: now - 6 },
{ repeater_id: 3112001, slot: 2, status: 'active', is_assumed: true,
is_data: false, call_type: 'group', src_id: 3188765, dst_id: 3120, start_time: now - 6 },
{ repeater_id: 3112002, slot: 2, status: 'active', is_assumed: true,
is_data: false, call_type: 'group', src_id: 3188765, dst_id: 3120, start_time: now - 6 },
// Call B: W0ABC keys US-Nationwide (31) on TS1 → egress OUT the trunk
{ repeater_id: 3112001, slot: 1, status: 'active', is_assumed: false,
is_data: false, call_type: 'group', src_id: 3112001, dst_id: 31, start_time: now - 4 },
{ connection_type: 'openbridge', connection_name: 'KS-CORE', slot: 1,
status: 'active', is_assumed: true, is_data: false, call_type: 'group',
src_id: 3112001, callsign: 'W0ABC', dst_id: 31, start_time: now - 4 },
// Call C: MO-Local (3100) just ended on KD0XYZ TS1 → TX hang
{ repeater_id: 3112002, slot: 1, status: 'hang_time', is_assumed: true,
is_data: false, call_type: 'group', src_id: 3161001, dst_id: 3100, start_time: now - 22 },
];
state.last_heard = [
{ radio_id: 3188765, callsign: 'N0CAL', talkgroup: 3120, slot: 2,
call_type: 'group', is_data: false, active: true,
source_name: 'KS-CORE', last_heard: now - 6 },
{ radio_id: 3112001, callsign: 'W0ABC', talkgroup: 31, slot: 1,
call_type: 'group', is_data: false, active: true,
source_name: 'W0ABC', source_repeater_id: 3112001, last_heard: now - 4 },
{ radio_id: 3161001, callsign: 'W0DEF', talkgroup: 3100, slot: 1,
call_type: 'group', is_data: false, active: false,
source_name: 'KD0XYZ', source_repeater_id: 3112002, last_heard: now - 22 },
{ radio_id: 3112003, callsign: 'KE0FGH', talkgroup: 3112001, slot: 2,
call_type: 'private', is_data: false, active: false,
source_name: 'N0MJS', source_repeater_id: 3112003, last_heard: now - 95 },
{ radio_id: 3112002, callsign: 'KD0XYZ', talkgroup: 3100, slot: 1,
call_type: 'group', is_data: true, active: false,
source_name: 'KD0XYZ', source_repeater_id: 3112002, last_heard: now - 140 },
{ radio_id: 3129100, callsign: '', talkgroup: 31, slot: 1,
call_type: 'group', is_data: false, active: false,
source_name: 'KC-REGIONAL', last_heard: now - 305 },
];
state.stats = { total_calls_today: 1287, total_duration_today: 14520, retransmitted_calls: 342 };
state.events = [];
state.user_db_status = null;
updateHBlinkStatus(true);
updateWebSocketStatus(true);
updateRepeaters(state.repeaters);
updateOutbounds(state.outbounds);
updateOpenbridges(state.openbridges);
updateEvents(state.events);
updateStats(state.stats, state.repeaters, state.streams);
updateLastHeard(state.last_heard);
updateUserDbStatus(state.user_db_status);
}
// Initialize dashboard
initCollapsibles();
loadConfig();
if (new URLSearchParams(location.search).has('demo')) {
loadDemo();
} else {
connectWebSocket();
}
</script>
</body>
</html>