mirror of
https://github.com/n0mjs710/HBlink4.git
synced 2026-11-08 04:19:26 +00:00
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>
2830 lines
129 KiB
HTML
2830 lines
129 KiB
HTML
<!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> |