Files
meshcore-bot/modules/web_viewer/templates/realtime.html
T

2301 lines
91 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Real-time Monitoring - MeshCore Bot Data Viewer{% endblock %}
{% block extra_css %}
<style>
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.status-connected { background-color: #28a745; }
.status-disconnected { background-color: #dc3545; }
.status-warning { background-color: #ffc107; }
.stream-container {
height: calc(100vh - 300px);
min-height: 400px;
overflow-y: auto;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 0.5rem;
}
/* Dark mode: scrollbars for Command / Channel Messages / Packet streams (Firefox + WebKit) */
[data-theme="dark"] .stream-container {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.28) var(--bg-tertiary);
}
[data-theme="dark"] .stream-container::-webkit-scrollbar {
width: 10px;
}
[data-theme="dark"] .stream-container::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 0.25rem;
}
[data-theme="dark"] .stream-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
border-radius: 0.25rem;
border: 2px solid var(--bg-tertiary);
}
[data-theme="dark"] .stream-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35);
}
/* Packet stream: no horizontal scroll; long paths wrap within the card */
#packet-stream {
overflow-x: hidden;
}
#packet-stream .stream-entry > .d-flex {
min-width: 0;
}
#packet-stream .stream-entry .flex-grow-1 {
min-width: 0;
}
#packet-stream .packet-stream-path {
overflow-wrap: anywhere;
word-break: break-word;
}
.stream-entry {
background-color: var(--card-bg);
transition: all 0.3s ease;
border-left: 3px solid #6c757d;
color: var(--text-color);
}
/* SNR / hops: compact chips; colors match Packet Stream (bg-primary / bg-info). */
.live-msg-meta-badge {
font-size: 0.62rem;
font-weight: 500;
line-height: 1.15;
padding: 0.18em 0.42em;
vertical-align: middle;
}
.live-msg-meta-stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
max-width: 100%;
}
.live-msg-meta-badges-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem;
}
.live-msg-time {
font-size: 0.58rem;
line-height: 1.2;
opacity: 0.88;
}
.stream-entry:hover {
box-shadow: 0 2px 8px var(--shadow-color);
transform: translateY(-1px);
}
.stream-entry:first-child {
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.2);
}
/* Packet type color-coding */
.packet-advert { border-left-color: #fd7e14; } /* Orange for Adverts */
.packet-grouptext { border-left-color: #0dcaf0; } /* Cyan for GroupText */
.packet-txt { border-left-color: #198754; } /* Green for direct Text */
.packet-ack { border-left-color: #6c757d; } /* Gray for ACK */
.packet-ping { border-left-color: #ffc107; } /* Yellow for Ping */
.packet-pong { border-left-color: #ffc107; } /* Yellow for Pong */
.packet-req { border-left-color: #0d6efd; } /* Blue for Request */
.packet-resp { border-left-color: #0d6efd; } /* Blue for Response */
.packet-trace { border-left-color: #6f42c1; } /* Purple for Trace */
.packet-cmd { border-left-color: #d63384; } /* Pink for Command */
.packet-unknown { border-left-color: #6c757d; } /* Gray for Unknown */
/* Clickable packet entries */
.packet-clickable {
cursor: pointer;
}
.packet-clickable:hover {
background-color: var(--bg-tertiary);
border-left-color: #17a2b8;
}
.click-hint {
margin-left: 8px;
color: var(--text-muted);
font-size: 0.8em;
}
.packet-clickable:hover .click-hint {
color: #17a2b8;
}
.badge {
font-size: 0.75em;
}
.bg-purple {
background-color: #6f42c1 !important;
color: #fff;
}
.text-warning {
color: #fd7e14 !important;
}
.conversation-entry {
border-left: 3px solid #007bff;
padding-left: 10px;
margin-left: 5px;
}
.user-message {
background-color: #e3f2fd;
border: 1px solid #bbdefb;
box-shadow: 0 1px 3px var(--shadow-color);
color: var(--text-color);
}
[data-theme="dark"] .user-message {
background-color: #1a237e;
border-color: #283593;
color: #e3f2fd;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.bot-message-success {
background-color: #e8f5e8;
border: 1px solid #c8e6c9;
box-shadow: 0 1px 3px var(--shadow-color);
color: var(--text-color);
}
[data-theme="dark"] .bot-message-success {
background-color: #1b5e20;
border-color: #2e7d32;
color: #c8e6c9;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.bot-message-danger {
background-color: #ffebee;
border: 1px solid #ffcdd2;
box-shadow: 0 1px 3px var(--shadow-color);
color: var(--text-color);
}
[data-theme="dark"] .bot-message-danger {
background-color: #4a148c;
border-color: #6a1b9a;
color: #f8bbd0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.response-text {
white-space: pre-wrap;
word-break: break-word;
}
/* Packet Detail Modal Styles */
.packet-detail-body {
background-color: #1a1d21;
color: #e9ecef;
max-height: 70vh;
overflow-y: auto;
}
#packetDetailModal .modal-content {
background-color: #1a1d21;
border: 1px solid #343a40;
}
#packetDetailModal .modal-header {
background-color: #212529;
border-bottom: 1px solid #343a40;
color: #e9ecef;
}
#packetDetailModal .modal-footer {
background-color: #212529;
border-top: 1px solid #343a40;
}
.packet-info-item {
margin-bottom: 0.5rem;
}
.packet-info-label {
color: #adb5bd;
margin-right: 0.5rem;
}
.packet-hash {
color: #ffc107;
font-size: 1.1em;
}
.section-title {
color: #17a2b8;
border-bottom: 1px solid #343a40;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
/* Hex Breakdown */
.hex-breakdown-container {
background-color: #212529;
border-radius: 0.375rem;
padding: 1rem;
}
.hex-breakdown {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85rem;
line-height: 1.8;
word-break: break-all;
background-color: #2d3238;
padding: 1rem;
border-radius: 0.25rem;
border-left: 4px solid #ffc107;
}
/* Hex segment colors - high contrast for dark mode */
.hex-header { color: #000; background-color: #ffc107; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
.hex-pathlen { color: #000; background-color: #17a2b8; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
.hex-path { color: #fff; background-color: #28a745; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
.hex-payload { color: #fff; background-color: #0d6efd; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
.hex-transport { color: #000; background-color: #fd7e14; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
.hex-unknown { color: #adb5bd; }
/* GroupText hex colors - high contrast for dark mode */
.hex-channel-hash { color: #fff; background-color: #dc3545; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
.hex-cipher-mac { color: #000; background-color: #ffc107; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
.hex-ciphertext { color: #000; background-color: #17a2b8; padding: 2px 4px; border-radius: 2px; font-weight: 600; }
/* Segment Cards */
.segment-card {
background-color: #212529;
border: 1px solid #343a40;
border-radius: 0.375rem;
padding: 1rem;
height: 100%;
}
.segment-title {
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.segment-bytes {
font-size: 0.75rem;
color: #6c757d;
margin-bottom: 0.5rem;
}
.segment-value {
display: block;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85rem;
background-color: #2d3238;
padding: 0.5rem;
border-radius: 0.25rem;
word-break: break-all;
color: #e9ecef;
}
.segment-description {
font-size: 0.8rem;
color: #adb5bd;
margin-top: 0.5rem;
margin-bottom: 0;
}
.text-truncate-multi {
max-height: 4.5em;
overflow: hidden;
text-overflow: ellipsis;
}
/* Header bits table */
.header-bits-table {
font-size: 0.8rem;
margin-bottom: 0;
}
.header-bits-table th {
color: #adb5bd;
font-weight: 500;
border-color: #343a40 !important;
}
.header-bits-table td {
border-color: #343a40 !important;
color: #e9ecef;
}
/* Decrypted message card */
.decrypted-card {
background-color: rgba(40, 167, 69, 0.1);
border-color: #28a745;
}
.decrypted-field {
margin-bottom: 0.25rem;
}
.decrypted-label {
color: #adb5bd;
font-size: 0.85rem;
}
.decrypted-value {
color: #e9ecef;
font-weight: 500;
}
.message-content {
background-color: #2d3238;
padding: 0.75rem;
border-radius: 0.25rem;
font-family: inherit;
white-space: pre-wrap;
word-break: break-word;
margin-top: 0.5rem;
}
/* GroupText section */
#grouptext-breakdown {
display: none;
background-color: #212529;
border: 1px solid #343a40;
border-radius: 0.375rem;
padding: 1rem;
margin-top: 1rem;
}
#grouptext-breakdown.visible {
display: block !important;
}
/* Advert section */
#advert-breakdown {
display: none;
background-color: #212529;
border: 1px solid #343a40;
border-radius: 0.375rem;
padding: 1rem;
margin-top: 1rem;
}
#advert-breakdown.show-section {
display: block !important;
}
/* Light mode support for modal */
[data-theme="light"] .packet-detail-body,
[data-theme="light"] #packetDetailModal .modal-content {
background-color: var(--bg-secondary);
color: var(--text-color);
}
[data-theme="light"] #packetDetailModal .modal-header,
[data-theme="light"] #packetDetailModal .modal-footer {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-color);
}
[data-theme="light"] .segment-card {
background-color: var(--card-bg);
border-color: var(--border-color);
}
[data-theme="light"] .segment-value {
background-color: #e9ecef;
color: #212529;
}
[data-theme="light"] .hex-breakdown {
background-color: var(--bg-tertiary);
border-left-color: #ffc107;
}
[data-theme="light"] .hex-breakdown-container {
background-color: var(--card-bg);
}
[data-theme="light"] .section-title {
color: #0d6efd;
border-bottom-color: var(--border-color);
}
[data-theme="light"] .packet-info-label {
color: var(--text-muted);
}
[data-theme="light"] .segment-bytes,
[data-theme="light"] .segment-description {
color: var(--text-muted);
}
/* Override Bootstrap table-dark in light mode */
[data-theme="light"] .header-bits-table,
[data-theme="light"] .header-bits-table.table-dark {
--bs-table-bg: var(--bg-secondary);
--bs-table-color: var(--text-color);
color: var(--text-color);
background-color: var(--bg-secondary);
}
[data-theme="light"] .header-bits-table th,
[data-theme="light"] .header-bits-table.table-dark th {
border-color: var(--border-color) !important;
color: #495057 !important;
background-color: #f8f9fa !important;
}
[data-theme="light"] .header-bits-table td,
[data-theme="light"] .header-bits-table.table-dark td {
border-color: var(--border-color) !important;
color: #212529 !important;
background-color: #ffffff !important;
}
[data-theme="light"] .message-content {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
[data-theme="light"] #grouptext-breakdown,
[data-theme="light"] #advert-breakdown {
background-color: var(--card-bg);
border-color: var(--border-color);
}
[data-theme="light"] .decrypted-label {
color: #495057;
}
[data-theme="light"] .decrypted-value {
color: var(--text-color);
}
[data-theme="light"] .decrypted-card {
background-color: rgba(25, 135, 84, 0.1);
border-color: #198754;
}
</style>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0">
<i class="fas fa-broadcast-tower"></i> Real-time Monitoring
</h1>
<a href="/logs" class="btn btn-outline-secondary">
<i class="fas fa-file-alt"></i> Live Log Viewer
</a>
</div>
</div>
<!-- Stream Filters -->
<div class="row mb-3">
<div class="col-12">
<div class="d-flex align-items-center flex-wrap gap-2">
<small class="text-muted me-1">Show:</small>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input rt-filter-cb" type="checkbox" id="rt-filter-command" data-target="command-card" checked>
<label class="form-check-label" for="rt-filter-command" style="font-size:0.8rem;color:#6c757d">Commands</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input rt-filter-cb" type="checkbox" id="rt-filter-packet" data-target="packet-card" checked>
<label class="form-check-label" for="rt-filter-packet" style="font-size:0.8rem;color:#0dcaf0">Packets</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input rt-filter-cb" type="checkbox" id="rt-filter-message" data-target="message-card" checked>
<label class="form-check-label" for="rt-filter-message" style="font-size:0.8rem;color:#0d6efd">Messages</label>
</div>
</div>
</div>
</div>
<!-- Stream panels: XL = 3 columns; MDLG = Command + Messages, then Packet; XS = Command, Packet, Messages -->
<div class="row realtime-streams align-items-stretch g-3 mb-4">
<div class="col-12 col-md-6 col-xl-4 order-1 order-md-1 order-xl-1">
<div class="card h-100" id="command-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6><i class="fas fa-comments"></i> Command Stream</h6>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning" id="command-status">Connecting…</span>
<button class="btn btn-sm btn-outline-secondary ms-2" id="cmd-scroll-top" title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="cmd-scroll-bottom" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="cmd-clear">Clear</button>
</div>
</div>
<div class="card-body">
<div id="command-stream" class="stream-container">
<div class="text-muted text-center py-3">
<i class="fas fa-hourglass-half"></i> Waiting for command data...
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 order-3 order-md-2 order-xl-3">
<div class="card h-100" id="message-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6><i class="fas fa-comment-dots"></i> Live Channel Messages</h6>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning" id="message-status">Connecting…</span>
<button class="btn btn-sm btn-outline-secondary ms-2" id="msg-scroll-top" title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="msg-scroll-bottom" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="msg-clear">Clear</button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="msg-pause-btn">
<i class="fas fa-pause" id="msg-pause-icon"></i> Pause
</button>
</div>
</div>
<div class="card-body">
<div id="message-stream" class="stream-container">
<div class="text-muted text-center py-3">
<i class="fas fa-hourglass-half"></i> Waiting for channel messages…
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-12 col-xl-4 order-2 order-md-3 order-xl-2">
<div class="card h-100" id="packet-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6><i class="fas fa-route"></i> Packet Stream</h6>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning" id="packet-status">Connecting…</span>
<button class="btn btn-sm btn-outline-secondary ms-2" id="pkt-scroll-top" title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="pkt-scroll-bottom" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="pkt-clear">Clear</button>
</div>
</div>
<div class="card-body">
<div id="packet-stream" class="stream-container">
<div class="text-muted text-center py-3">
<i class="fas fa-hourglass-half"></i> Waiting for packet data...
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Packet Detail Modal -->
<div class="modal fade" id="packetDetailModal" tabindex="-1" aria-labelledby="packetDetailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="packetDetailModalLabel">
<i class="fas fa-microchip"></i> Packet Byte Breakdown
<span id="packet-total-bytes" class="badge bg-secondary ms-2"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body packet-detail-body">
<!-- Packet Header Info -->
<div class="row mb-3">
<div class="col-md-6">
<div class="packet-info-item">
<span class="packet-info-label">Packet Hash:</span>
<code id="detail-packet-hash" class="packet-hash"></code>
</div>
<div class="packet-info-item">
<span class="packet-info-label">Type:</span>
<span id="detail-payload-type"></span>
</div>
<div class="packet-info-item">
<span class="packet-info-label">Heard at:</span>
<span id="detail-timestamp"></span>
</div>
</div>
<div class="col-md-6">
<div class="packet-info-item">
<span class="packet-info-label">Route:</span>
<span id="detail-route-type"></span>
</div>
<div class="packet-info-item">
<span class="packet-info-label">Path:</span>
<span id="detail-path"></span>
</div>
</div>
</div>
<!-- Full Hex Breakdown -->
<div class="hex-breakdown-container mb-4">
<h6 class="section-title">Raw Packet Data</h6>
<div id="hex-breakdown" class="hex-breakdown"></div>
</div>
<!-- Header Analysis -->
<div class="row mb-4">
<div class="col-md-6">
<div class="segment-card">
<h6 class="segment-title text-warning">Header</h6>
<div class="segment-bytes" id="header-bytes">Bytes 0-0</div>
<code id="header-value" class="segment-value"></code>
<table class="table table-sm table-dark header-bits-table mt-2">
<thead>
<tr>
<th>BITS</th>
<th>FIELD</th>
<th>VALUE</th>
<th>BINARY</th>
</tr>
</thead>
<tbody id="header-bits-body">
</tbody>
</table>
</div>
</div>
<div class="col-md-6">
<div class="segment-card">
<h6 class="segment-title text-info">Path Length</h6>
<div class="segment-bytes" id="pathlen-bytes">Bytes 1-1</div>
<code id="pathlen-value" class="segment-value"></code>
<p id="pathlen-description" class="segment-description"></p>
</div>
</div>
</div>
<!-- Path and Payload -->
<div class="row mb-4">
<div class="col-md-6">
<div class="segment-card">
<h6 class="segment-title text-success">Path Data</h6>
<div class="segment-bytes" id="path-bytes">Bytes 2-?</div>
<code id="path-value" class="segment-value"></code>
<p class="segment-description">Historical route taken (bytes are added as packet floods through network)</p>
</div>
</div>
<div class="col-md-6">
<div class="segment-card">
<h6 class="segment-title text-primary">Payload</h6>
<div class="segment-bytes" id="payload-bytes">Bytes ?-?</div>
<code id="payload-value" class="segment-value text-truncate-multi"></code>
<p id="payload-description" class="segment-description"></p>
</div>
</div>
</div>
<!-- GroupText Payload Breakdown (shown only for GroupText packets) -->
<div id="grouptext-breakdown" class="grouptext-section">
<h6 class="section-title">GroupText Payload Byte Breakdown</h6>
<div id="grouptext-hex" class="hex-breakdown mb-3"></div>
<div class="row">
<div class="col-md-4">
<div class="segment-card">
<h6 class="segment-title text-danger">Channel Hash</h6>
<div class="segment-bytes">Bytes 0-0</div>
<code id="channel-hash-value" class="segment-value"></code>
<p class="segment-description">First byte of SHA256 of channel's shared key</p>
</div>
</div>
<div class="col-md-4">
<div class="segment-card">
<h6 class="segment-title text-warning">Cipher MAC</h6>
<div class="segment-bytes">Bytes 1-2</div>
<code id="cipher-mac-value" class="segment-value"></code>
<p class="segment-description">MAC for encrypted data</p>
</div>
</div>
<div class="col-md-4">
<div class="segment-card">
<h6 class="segment-title text-info">Ciphertext</h6>
<div class="segment-bytes" id="ciphertext-bytes">Bytes 3-?</div>
<code id="ciphertext-value" class="segment-value text-truncate-multi"></code>
<p class="segment-description">Encrypted message content (timestamp + flags + message)</p>
</div>
</div>
</div>
<!-- Decrypted Message (if available) -->
<div id="decrypted-section" class="mt-3" style="display: none;">
<div class="segment-card decrypted-card">
<h6 class="segment-title text-success"><i class="fas fa-unlock"></i> Decrypted Message</h6>
<div class="row">
<div class="col-md-4">
<div class="decrypted-field">
<span class="decrypted-label">Sender:</span>
<span id="decrypted-sender" class="decrypted-value"></span>
</div>
</div>
<div class="col-md-4">
<div class="decrypted-field">
<span class="decrypted-label">Timestamp:</span>
<span id="decrypted-timestamp" class="decrypted-value"></span>
</div>
</div>
<div class="col-md-4">
<div class="decrypted-field">
<span class="decrypted-label">Channel:</span>
<span id="decrypted-channel" class="decrypted-value"></span>
</div>
</div>
</div>
<div class="decrypted-message mt-2">
<span class="decrypted-label">Message:</span>
<div id="decrypted-message" class="message-content"></div>
</div>
</div>
</div>
</div>
<!-- Advert Payload Breakdown (shown only for Advert packets) -->
<div id="advert-breakdown">
<h6 class="section-title">Advertisement Payload Breakdown</h6>
<div class="row">
<div class="col-md-6">
<div class="segment-card">
<h6 class="segment-title text-info">Public Key</h6>
<code id="advert-pubkey" class="segment-value text-truncate-multi"></code>
</div>
</div>
<div class="col-md-6">
<div class="segment-card">
<h6 class="segment-title text-success">Node Info</h6>
<div id="advert-node-info"></div>
</div>
</div>
</div>
</div>
<!-- Decoder Error -->
<div id="decoder-error" class="alert alert-warning" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<span id="decoder-error-message"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-primary" id="copy-raw-packet">
<i class="fas fa-copy"></i> Copy Raw Hex
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script nonce="{{ g.csp_nonce }}">
// Load meshcore-decoder asynchronously so it doesn't block socket init.
// Dynamic import() works in regular script blocks on all modern browsers and
// avoids module-script scope/timing issues that previously prevented
// socket.on('connect') from firing (the module deferred execution caused a
// race between two Socket.IO managers, with the module's socket never
// receiving its connect event).
import('/static/js/meshcore-browser.mjs').then(function(m) {
window.meshCoreDecoder = m;
window._MeshCoreKeyStore = m.MeshCoreKeyStore;
}).catch(function(e) {
console.warn('MeshCore decoder unavailable:', e);
});
// Reuse the shared socket from base.html's ModernConnectionManager
const socket = window.connectionManager.socket;
// Connection status
let commandConnected = false;
let packetConnected = false;
// Storage for packet data (for click-to-detail feature)
const packetStore = new Map();
window.packetStore = packetStore; // Make accessible globally
let packetCounter = 0;
// Public channel secret (well-known, publicly documented)
const PUBLIC_CHANNEL_SECRET = '8b3387e9c5cdea6ac9e5edbaa115cd72';
// Channel secrets storage (Public + derived hashtag keys)
let channelSecrets = [PUBLIC_CHANNEL_SECRET];
// Channel hash to name mapping (for displaying friendly channel names)
// Maps hash -> array of {name, secret} since multiple channels can share a hash
const channelHashMap = new Map();
window.channelHashMap = channelHashMap;
// MeshCore decoder key store
let decoderKeyStore = null;
// Current packet being viewed in modal
let currentPacketHex = null;
window.currentPacketHex = null; // Make accessible globally
// Identify which channel a message belongs to by trying decryption with each candidate key
async function identifyChannelByDecryption(channelEntries, fullPacketHex) {
if (!window.meshCoreDecoder || !channelEntries || channelEntries.length === 0) {
return channelEntries?.map(e => e.name).join(' or ') || 'Unknown';
}
// Try decryption with each candidate key individually
for (const entry of channelEntries) {
try {
// Create a key store with just this one secret
const testKeyStore = new (window._MeshCoreKeyStore || class { addChannelSecrets(){} })({
channelSecrets: [entry.secret]
});
// Try to decode the full packet with just this key
const testResult = window.meshCoreDecoder.decodePacket(fullPacketHex, { keyStore: testKeyStore });
// If decryption succeeded (has decrypted content), this is the right key
if (testResult?.payload?.decoded?.decrypted) {
return entry.name;
}
} catch (e) {
// This key didn't work, try next
continue;
}
}
// Couldn't determine - show all possibilities
return channelEntries.map(e => e.name).join(' or ');
}
// Compute channel hash from secret (first byte of SHA256 of the secret)
async function computeChannelHash(secretHex) {
try {
// Convert hex secret to bytes
const secretBytes = new Uint8Array(secretHex.match(/.{1,2}/g).map(b => parseInt(b, 16)));
// Use js-sha256 library
if (typeof sha256 !== 'undefined') {
const hashHex = sha256(secretBytes);
return hashHex.slice(0, 2).toUpperCase(); // First byte as hex
}
// Fallback to Web Crypto API
if (window.crypto && window.crypto.subtle) {
const hashBuffer = await crypto.subtle.digest('SHA-256', secretBytes);
const hashArray = new Uint8Array(hashBuffer);
return hashArray[0].toString(16).padStart(2, '0').toUpperCase();
}
} catch (e) {
console.warn('Could not compute channel hash:', e);
}
return null;
}
// Initialize decoder with channel secrets
async function initializeKeyStore() {
try {
// Fetch configured channels
const response = await fetch('/api/channels');
const data = await response.json();
let radioChannels = 0;
let decodeOnlyChannels = 0;
// Add Public channel to maps
const publicHash = await computeChannelHash(PUBLIC_CHANNEL_SECRET);
if (publicHash) {
channelHashMap.set(publicHash, [{ name: 'Public', secret: PUBLIC_CHANNEL_SECRET }]);
}
if (data.channels && data.channels.length > 0) {
// Derive keys for hashtag channels
for (const channel of data.channels) {
if (channel.type === 'hashtag' && channel.name) {
const derivedKey = await deriveHashtagKey(channel.name);
if (derivedKey && !channelSecrets.includes(derivedKey)) {
channelSecrets.push(derivedKey);
// Store with # prefix for display
const displayName = channel.name.startsWith('#') ? channel.name : `#${channel.name}`;
// Add to hash map (handles collisions by storing array)
const channelHash = await computeChannelHash(derivedKey);
if (channelHash) {
const existing = channelHashMap.get(channelHash) || [];
existing.push({ name: displayName, secret: derivedKey });
channelHashMap.set(channelHash, existing);
}
if (channel.decode_only) {
decodeOnlyChannels++;
} else {
radioChannels++;
}
}
}
}
}
// Create the decoder key store with all channel secrets
decoderKeyStore = window._MeshCoreKeyStore ? new window._MeshCoreKeyStore({
channelSecrets: channelSecrets
}) : null;
window.decoderKeyStore = decoderKeyStore;
let logMsg = `Initialized MeshCore decoder with ${channelSecrets.length} channel keys (Public`;
if (radioChannels > 0) logMsg += ` + ${radioChannels} radio`;
if (decodeOnlyChannels > 0) logMsg += ` + ${decodeOnlyChannels} decode-only`;
logMsg += ')';
console.log(logMsg);
} catch (error) {
console.warn('Could not initialize decoder key store:', error);
}
}
// Derive hashtag channel key: first 16 bytes of SHA256(lowercase_channel_name)
async function deriveHashtagKey(channelName) {
try {
// Ensure channel name starts with # and is lowercase
let name = channelName.toLowerCase();
if (!name.startsWith('#')) {
name = '#' + name;
}
// Use js-sha256 library (works in non-HTTPS contexts)
if (typeof sha256 !== 'undefined') {
const hashHex = sha256(name);
return hashHex.slice(0, 32); // First 16 bytes as hex
}
// Fallback to Web Crypto API (only works in secure contexts)
if (window.crypto && window.crypto.subtle) {
const encoder = new TextEncoder();
const data = encoder.encode(name);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = new Uint8Array(hashBuffer);
return Array.from(hashArray.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join('');
}
console.warn('No SHA-256 implementation available');
return null;
} catch (error) {
console.warn('Could not derive hashtag key:', error);
return null;
}
}
// Load recent commands on page load
function loadRecentCommands() {
fetch('/api/recent_commands')
.then(response => response.json())
.then(data => {
if (data.commands && data.commands.length > 0) {
console.log(`Loading ${data.commands.length} recent commands`);
// Clear the waiting message
const container = document.getElementById('command-stream');
const waiting = container.querySelector('.text-muted.text-center');
if (waiting) {
waiting.remove();
}
// Add each command (in reverse order since they come newest first)
data.commands.reverse().forEach(commandData => {
addCommandEntry(commandData);
});
updateStatus('command-status', 'Loaded Recent', 'info');
}
})
.catch(error => {
console.error('Error loading recent commands:', error);
});
}
// Socket event handlers
socket.on('connect', function() {
console.log('Connected to server');
updateStatus('command-status', 'Connected', 'success');
updateStatus('packet-status', 'Connected', 'success');
updateStatus('message-status', 'Connected', 'success');
socket.emit('subscribe_commands');
socket.emit('subscribe_packets');
socket.emit('subscribe_messages');
// Load recent commands when connected
loadRecentCommands();
// Initialize key store for decryption
initializeKeyStore();
// Start ping interval to keep connection alive
startPingInterval();
});
socket.on('disconnect', function() {
console.log('Disconnected from server');
updateStatus('command-status', 'Disconnected', 'danger');
updateStatus('packet-status', 'Disconnected', 'danger');
updateStatus('message-status', 'Disconnected', 'danger');
// Stop ping interval on disconnect
stopPingInterval();
});
socket.on('force_disconnect', function(data) {
console.log('Server requested disconnect:', data.reason);
updateStatus('command-status', 'Disconnected by server', 'warning');
updateStatus('packet-status', 'Disconnected by server', 'warning');
// Disconnect the socket
socket.disconnect();
});
socket.on('error', function(data) {
console.error('Socket error:', data.message);
updateStatus('command-status', 'Error', 'danger');
updateStatus('packet-status', 'Error', 'danger');
});
socket.on('status', function(data) {
console.log('Status:', data.message);
});
socket.on('command_data', function(data) {
addCommandEntry(data);
});
socket.on('packet_data', function(data) {
addPacketEntry(data);
});
socket.on('message_data', function(data) {
addMessageEntry(data);
});
// ── Live Channel Messages ──────────────────────────────────────────────
let messagePaused = false;
const MAX_MESSAGES = 200;
function makeElement(tagName, className = '', text = null) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (text !== null && text !== undefined) element.textContent = String(text);
return element;
}
function makeIcon(className) {
return makeElement('i', className);
}
function setButtonIconAndLabel(button, iconClass, label) {
const icon = makeIcon(iconClass);
button.replaceChildren(icon, document.createTextNode(` ${String(label)}`));
}
function renderWaitingState(container, message) {
const placeholder = makeElement('div', 'text-muted text-center py-3');
placeholder.append(
makeIcon('fas fa-hourglass-half'),
document.createTextNode(` ${String(message)}`)
);
container.replaceChildren(placeholder);
}
function toggleMessagePause() {
messagePaused = !messagePaused;
const btn = document.getElementById('msg-pause-btn');
if (messagePaused) {
setButtonIconAndLabel(btn, 'fas fa-play', 'Resume');
updateStatus('message-status', 'Paused', 'warning');
} else {
setButtonIconAndLabel(btn, 'fas fa-pause', 'Pause');
updateStatus('message-status', 'Active', 'success');
}
}
function clearMessages() {
renderWaitingState(
document.getElementById('message-stream'),
'Waiting for channel messages…'
);
}
function normalizeChannelKeyForColor(channel) {
if (!channel) return '';
let s = String(channel).trim().toLowerCase();
if (s.startsWith('#')) s = s.slice(1);
return s;
}
function hashStringToUint(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/** Hue 0359: mix normalized + raw label so e.g. Public vs #testing rarely collide or look identical. */
function channelHueFromRaw(channelRaw) {
if (!channelRaw) return 200;
const s = String(channelRaw).trim();
const norm = normalizeChannelKeyForColor(s);
const mixed = norm + '\x1e' + s;
let h = hashStringToUint(mixed);
h ^= h >>> 16;
h = Math.imul(h, 2246822519);
h ^= h >>> 13;
h = Math.imul(h, 3266489917);
h ^= h >>> 16;
return (h >>> 0) % 360;
}
function isLightThemeActive() {
return document.documentElement.getAttribute('data-theme') === 'light';
}
/** Stable HSL per channel — saturated enough to feel lively, not maxed-out neon (DM uses fixed purple). */
function channelAccentStyles(channelRaw) {
const hue = channelHueFromRaw(channelRaw);
const h32 = hashStringToUint(normalizeChannelKeyForColor(String(channelRaw || '')) + '\x1e' + String(channelRaw || ''));
const t = (h32 >>> 8) % 11;
if (isLightThemeActive()) {
const borderSat = 42 + (t % 10);
const badgeSat = 34 + (t % 12);
return {
border: 'hsl(' + hue + ', ' + borderSat + '%, 42%)',
badgeBg: 'hsl(' + hue + ', ' + badgeSat + '%, 91%)',
badgeFg: 'hsl(' + hue + ', 45%, 20%)'
};
}
const borderSat = 48 + (t % 14);
const badgeSat = 42 + (t % 14);
return {
border: 'hsl(' + hue + ', ' + borderSat + '%, 56%)',
badgeBg: 'hsl(' + hue + ', ' + badgeSat + '%, 32%)',
badgeFg: 'hsl(' + hue + ', 15%, 97%)'
};
}
/** Remove redundant leading [#chan] / [##chan] already shown in the header badge. */
function stripLeadingChannelBracketTag(content, isDm) {
if (isDm || content == null || content === '') return content;
return String(content).replace(/^\s*\[[#]+[^\]]*\]\s*/u, '');
}
function addMessageEntry(data) {
if (messagePaused) return;
const container = document.getElementById('message-stream');
// Remove placeholder
const placeholder = container.querySelector('.text-muted.text-center');
if (placeholder) placeholder.remove();
const ts = new Date(data.timestamp * 1000).toLocaleTimeString();
const isDm = !!data.is_dm;
const accent = isDm
? { border: '#6f42c1', badgeBg: '#6f42c1', badgeFg: '#fff' }
: channelAccentStyles(data.channel || '');
let channelBadge = null;
if (data.channel) {
channelBadge = makeElement(
'span',
'badge live-msg-channel-badge me-1',
data.channel
);
channelBadge.style.backgroundColor = accent.badgeBg;
channelBadge.style.color = accent.badgeFg;
}
const snrBadge = (data.snr && data.snr !== 'unknown')
? makeElement(
'span',
'badge rounded-pill bg-primary live-msg-meta-badge',
`SNR ${String(data.snr)}`
)
: null;
// 255 = unknown hop count from radio; omit badge (same as before).
let hopsBadge = null;
const h = data.hops;
if (h != null && h !== '' && h !== 255) {
const hopsText = h === 0 ? 'Direct' : `${String(h)} hop${h !== 1 ? 's' : ''}`;
hopsBadge = makeElement(
'span',
'badge rounded-pill bg-info text-dark live-msg-meta-badge',
hopsText
);
}
const bodyRaw = stripLeadingChannelBracketTag(data.content || '', isDm);
const entry = document.createElement('div');
entry.className = 'stream-entry p-2 mb-1 rounded';
entry.style.borderLeftColor = accent.border;
entry.style.borderLeftWidth = '3px';
entry.style.borderLeftStyle = 'solid';
const header = makeElement(
'div',
'd-flex justify-content-between align-items-start mb-1 flex-wrap gap-2 w-100'
);
const sender = makeElement('span', 'd-flex flex-wrap align-items-center gap-1 min-w-0');
if (channelBadge) sender.appendChild(channelBadge);
if (isDm) sender.appendChild(makeElement('span', 'badge bg-secondary me-1', 'DM'));
sender.appendChild(makeElement('strong', '', data.sender || '?'));
const metadata = makeElement('div', 'live-msg-meta-stack ms-auto flex-shrink-0 text-end');
if (snrBadge || hopsBadge) {
const badges = makeElement('div', 'live-msg-meta-badges-row');
if (snrBadge) badges.appendChild(snrBadge);
if (hopsBadge) badges.appendChild(hopsBadge);
metadata.appendChild(badges);
}
metadata.appendChild(makeElement('small', 'text-muted live-msg-time', ts));
header.append(sender, metadata);
entry.append(header, makeElement('div', 'text-break', bodyRaw));
container.insertBefore(entry, container.firstChild);
// Trim old entries
const entries = container.querySelectorAll('.stream-entry');
if (entries.length > MAX_MESSAGES) {
entries[entries.length - 1].remove();
}
}
function scrollStream(containerId, dir) {
const c = document.getElementById(containerId);
if (!c) return;
if (dir === 'top') {
c.scrollTop = 0;
} else {
c.scrollTop = c.scrollHeight;
}
}
// Stream panel visibility filters
document.querySelectorAll('.rt-filter-cb').forEach(function(cb) {
cb.addEventListener('change', function() {
const card = document.getElementById(this.dataset.target);
if (card) {
card.style.display = this.checked ? '' : 'none';
}
});
});
// Ping mechanism to keep connection alive
let pingInterval = null;
function startPingInterval() {
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(function() {
if (socket.connected) {
socket.emit('ping');
}
}, 25000); // Ping every 25 seconds (less than 30s timeout)
}
function stopPingInterval() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
socket.on('pong', function(data) {
console.log('Pong received from server');
// Connection is alive, update status
updateStatus('command-status', 'Connected', 'success');
updateStatus('packet-status', 'Connected', 'success');
});
// Helper functions
function updateStatus(elementId, text, type) {
const element = document.getElementById(elementId);
element.textContent = text;
element.className = `badge bg-${type}`;
}
function buildRepeatInfo(data) {
const repeatCount = Number(data.repeat_count) || 0;
if (repeatCount <= 0) return null;
const repeaterPrefixes = Array.isArray(data.repeater_prefixes)
? data.repeater_prefixes.map(prefix => String(prefix))
: [];
const repeaterCounts = data.repeater_counts || {};
let tooltipText;
if (repeaterPrefixes.length > 0) {
const prefixDetails = repeaterPrefixes.map(prefix => {
const count = Number(repeaterCounts[prefix]) || 1;
return count > 1 ? `${prefix} (${count}x)` : prefix;
}).join(', ');
tooltipText = `Repeater prefixes: ${prefixDetails}`;
if (repeatCount > repeaterPrefixes.length) {
tooltipText += `\n\nTotal: ${repeatCount} repeats from ${repeaterPrefixes.length} unique repeater${repeaterPrefixes.length !== 1 ? 's' : ''}`;
}
} else {
tooltipText = 'Heard by radio (no repeater prefixes identified)';
}
const info = makeElement('div', 'small text-info mt-1');
info.append(makeIcon('fas fa-broadcast-tower'), document.createTextNode(' Heard '));
const count = makeElement(
'span',
'text-decoration-underline',
`${repeatCount} repeat${repeatCount !== 1 ? 's' : ''}`
);
count.style.cursor = 'help';
count.dataset.bsToggle = 'tooltip';
count.dataset.bsPlacement = 'top';
count.title = tooltipText;
info.appendChild(count);
return info;
}
function populateBotCard(card, data) {
const responseClass = data.success ? 'success' : 'danger';
const botMessageClass = data.success ? 'bot-message-success' : 'bot-message-danger';
card.className = `bot-message p-2 rounded ${botMessageClass}`;
card.style.maxWidth = '80%';
const header = makeElement('div', 'd-flex align-items-center mb-1');
header.append(
makeElement('strong', 'text-success me-2', '🤖 Bot'),
makeElement(
'span',
`badge bg-${responseClass} small me-2`,
data.success ? 'Success' : 'Failed'
),
makeElement(
'small',
'text-muted ms-auto',
data.timestamp
? new Date(data.timestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString()
)
);
const response = makeElement('div', 'response-text', data.response || 'No response');
const repeatInfo = buildRepeatInfo(data);
card.replaceChildren(header, response);
if (repeatInfo) card.appendChild(repeatInfo);
}
function initializeEntryTooltips(entry) {
if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) return;
entry.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(element => {
const existingTooltip = bootstrap.Tooltip.getInstance(element);
if (existingTooltip) existingTooltip.dispose();
new bootstrap.Tooltip(element);
});
}
function addCommandEntry(data) {
const container = document.getElementById('command-stream');
// Remove waiting message if present
const waiting = container.querySelector('.text-muted.text-center');
if (waiting) {
waiting.remove();
}
// Check if we already have this command (by command_id) and update it instead
if (data.command_id) {
const commandId = String(data.command_id);
const existingEntry = Array.from(container.querySelectorAll('[data-command-id]'))
.find(entry => entry.dataset.commandId === commandId);
if (existingEntry) {
// Update existing entry with new repeat information
const botMessage = existingEntry.querySelector('.bot-message');
if (botMessage) {
populateBotCard(botMessage, data);
initializeEntryTooltips(botMessage);
}
return; // Don't create a new entry
}
}
// Create conversation-style entry
const entry = document.createElement('div');
entry.className = 'conversation-entry mb-3';
if (data.command_id) {
entry.dataset.commandId = String(data.command_id);
}
// User command message
const userMessage = document.createElement('div');
userMessage.className = 'd-flex justify-content-start mb-2';
const userCard = makeElement('div', 'user-message p-2 rounded');
userCard.style.maxWidth = '80%';
const userHeader = makeElement('div', 'd-flex align-items-center mb-1');
userHeader.append(
makeElement('strong', 'text-primary me-2', data.user || 'Unknown'),
makeElement('small', 'text-muted me-2', data.channel || 'Unknown'),
makeElement(
'small',
'text-muted ms-auto',
data.timestamp
? new Date(data.timestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString()
)
);
userCard.append(
userHeader,
makeElement('div', 'fw-bold', data.user_input || `/${data.command || 'Unknown Command'}`)
);
userMessage.appendChild(userCard);
// Bot response message
const botMessage = document.createElement('div');
botMessage.className = 'd-flex justify-content-end mb-2';
const botCard = document.createElement('div');
populateBotCard(botCard, data);
botMessage.appendChild(botCard);
entry.appendChild(userMessage);
entry.appendChild(botMessage);
container.insertBefore(entry, container.firstChild);
initializeEntryTooltips(entry);
// Limit to 25 conversation pairs (50 total messages)
while (container.children.length > 25) {
container.removeChild(container.lastChild);
}
updateStatus('command-status', 'Active', 'success');
}
function addPacketEntry(data) {
const container = document.getElementById('packet-stream');
// Remove waiting message if present
const waiting = container.querySelector('.text-muted.text-center');
if (waiting) {
waiting.remove();
}
// Store packet data for later access
const packetId = ++packetCounter;
packetStore.set(packetId, data);
// Format timestamp
const timestamp = data.datetime ? new Date(data.datetime).toLocaleTimeString() : new Date().toLocaleTimeString();
// Format path display as comma-separated list (path may be array or string)
const pathDisplay = data.path
? (Array.isArray(data.path) ? data.path.join(',') : String(data.path))
: 'No path';
// Format header with resolved components (human-readable with names and numbers)
let headerText = null;
if (data.header) {
const routeName = data.route_type_name || 'Unknown';
const routeValue = data.route_type !== undefined ? `0x${data.route_type.toString(16).padStart(2, '0')}` : 'Unknown';
const payloadName = data.payload_type_name || 'Unknown';
const payloadValue = data.payload_type !== undefined ? `0x${data.payload_type.toString(16).padStart(2, '0')}` : 'Unknown';
const version = data.payload_version !== undefined ? `v${data.payload_version}` : 'v?';
headerText = `Header: ${data.header} (${routeName} (${routeValue}) | ${payloadName} (${payloadValue}) | ${version})`;
}
// Format transport info (only show if present)
const transportText = data.has_transport_codes && data.transport_codes
? `Transport: ${data.transport_codes.hex || 'Unknown'}`
: null;
// Format payload info (only show if present)
const payloadText = data.payload_bytes > 0
? `Payload: ${data.payload_bytes} bytes`
: null;
// Format advert info (only show if this is an ADVERT packet with advert data)
const hasAdvertInfo = data.advert_name && data.payload_type_name === 'ADVERT';
// Check if raw packet hex is available for detailed view
const hasRawHex = !!(data.raw_packet_hex || data.payload_hex);
const clickableClass = hasRawHex ? 'packet-clickable' : '';
// Determine packet type class for color-coding
const payloadTypeName = (data.payload_type_name || data.payload_type || '').toString().toUpperCase();
let packetTypeClass = 'packet-unknown';
if (payloadTypeName.includes('ADVERT')) {
packetTypeClass = 'packet-advert';
} else if (payloadTypeName.includes('GRP') || payloadTypeName.includes('GROUP')) {
packetTypeClass = 'packet-grouptext';
} else if (payloadTypeName === 'TXT' || payloadTypeName === 'TEXT') {
packetTypeClass = 'packet-txt';
} else if (payloadTypeName.includes('ACK')) {
packetTypeClass = 'packet-ack';
} else if (payloadTypeName.includes('PING')) {
packetTypeClass = 'packet-ping';
} else if (payloadTypeName.includes('PONG')) {
packetTypeClass = 'packet-pong';
} else if (payloadTypeName.includes('REQ')) {
packetTypeClass = 'packet-req';
} else if (payloadTypeName.includes('RESP') || payloadTypeName.includes('RSP')) {
packetTypeClass = 'packet-resp';
} else if (payloadTypeName.includes('TRACE')) {
packetTypeClass = 'packet-trace';
} else if (payloadTypeName.includes('CMD') || payloadTypeName.includes('COMMAND')) {
packetTypeClass = 'packet-cmd';
}
const entry = document.createElement('div');
entry.className = `stream-entry mb-2 p-3 border rounded ${clickableClass} ${packetTypeClass}`;
entry.dataset.packetId = packetId;
if (hasRawHex) {
entry.addEventListener('click', () => showPacketDetail(packetId));
entry.title = 'Click to view packet details';
}
// Badge color based on packet type
const badgeColorMap = {
'packet-advert': 'bg-warning text-dark',
'packet-grouptext': 'bg-info',
'packet-txt': 'bg-success',
'packet-ack': 'bg-secondary',
'packet-ping': 'bg-warning text-dark',
'packet-pong': 'bg-warning text-dark',
'packet-req': 'bg-primary',
'packet-resp': 'bg-primary',
'packet-trace': 'bg-purple',
'packet-cmd': 'bg-danger',
'packet-unknown': 'bg-secondary'
};
const badgeColor = badgeColorMap[packetTypeClass] || 'bg-secondary';
const layout = makeElement('div', 'd-flex justify-content-between align-items-start');
const main = makeElement('div', 'flex-grow-1');
const heading = makeElement('div', 'd-flex align-items-center mb-2');
heading.append(
makeElement(
'div',
'fw-bold text-info me-2',
data.route_type_name || data.route_type || 'Unknown Route'
),
makeElement(
'span',
`badge ${badgeColor}`,
data.payload_type_name || data.payload_type || 'Unknown'
)
);
if (data.payload_version !== undefined) {
heading.appendChild(
makeElement('span', 'badge bg-light text-dark ms-1', `v${data.payload_version}`)
);
}
if (hasRawHex) {
const hint = makeElement('span', 'click-hint');
hint.appendChild(makeIcon('fas fa-search-plus'));
heading.appendChild(hint);
}
main.appendChild(heading);
const path = makeElement('div', 'small text-muted mb-1 packet-stream-path');
path.append(
makeElement('strong', '', 'Path:'),
document.createTextNode(` ${String(pathDisplay)}${data.path_len > 0 ? ` (${data.path_len} bytes)` : ''}`)
);
main.appendChild(path);
if (hasAdvertInfo) {
const advert = makeElement('div', 'small text-success');
advert.append(
makeElement('strong', '', 'Advertised:'),
document.createTextNode(` ${String(data.advert_name)} (${String(data.advert_mode || 'Unknown')})`)
);
main.appendChild(advert);
}
if (headerText) main.appendChild(makeElement('div', 'small text-muted', headerText));
if (transportText) main.appendChild(makeElement('div', 'small text-warning', transportText));
if (payloadText) main.appendChild(makeElement('div', 'small text-muted', payloadText));
const right = makeElement('div', 'text-end');
right.appendChild(makeElement('div', 'small text-muted mb-1', timestamp));
const packetBadges = makeElement('div', 'd-flex flex-column align-items-end');
packetBadges.appendChild(
makeElement('span', 'badge bg-info mb-1', `${data.hops || data.path_len || 0} hops`)
);
if (data.has_transport_codes) {
packetBadges.appendChild(makeElement('span', 'badge bg-warning small', 'Transport'));
}
right.appendChild(packetBadges);
layout.append(main, right);
entry.appendChild(layout);
container.insertBefore(entry, container.firstChild);
// Limit to 50 entries and clean up old packet data
while (container.children.length > 50) {
const lastChild = container.lastChild;
if (lastChild && lastChild.dataset && lastChild.dataset.packetId) {
const oldPacketId = parseInt(lastChild.dataset.packetId);
if (oldPacketId) {
packetStore.delete(oldPacketId);
}
}
container.removeChild(lastChild);
}
updateStatus('packet-status', 'Active', 'success');
}
// Show packet detail modal
async function showPacketDetail(packetId) {
const data = packetStore.get(packetId);
if (!data) {
console.error('Packet data not found:', packetId);
return;
}
// Get the raw packet hex
const rawHex = data.raw_packet_hex || data.payload_hex;
if (!rawHex) {
console.error('No raw packet hex available');
return;
}
currentPacketHex = rawHex;
window.currentPacketHex = rawHex;
// Try to decode with meshcore-decoder
let decoded = null;
let decodeError = null;
if (window.meshCoreDecoder) {
try {
const options = window.decoderKeyStore ? { keyStore: window.decoderKeyStore } : {};
decoded = window.meshCoreDecoder.decodePacket(rawHex, options);
} catch (error) {
decodeError = error.message;
console.warn('Decoder error:', error);
}
} else {
decodeError = 'MeshCore decoder not loaded';
}
// Build structure for hex breakdown
const structure = buildStructureFromDecodedData(decoded, data, rawHex);
// Populate modal with decoded data (prefer decoder output, fallback to server data)
await populatePacketModal(data, rawHex, decoded, structure, decodeError);
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('packetDetailModal'));
modal.show();
}
// Make showPacketDetail accessible globally
window.showPacketDetail = showPacketDetail;
// Build packet structure from decoded data
function buildStructureFromDecodedData(decoded, serverData, rawHex) {
const segments = [];
const totalBytes = rawHex.length / 2;
// Check for transport codes
const hasTransport = decoded?.hasTransport ?? serverData.has_transport_codes ?? false;
let offset = 0;
if (hasTransport) {
segments.push({
name: 'Transport',
startByte: 0,
endByte: 3,
value: rawHex.slice(0, 8).toUpperCase(),
description: 'Transport codes'
});
offset = 4;
}
// Header byte
segments.push({
name: 'Header',
startByte: offset,
endByte: offset,
value: '0x' + rawHex.slice(offset * 2, offset * 2 + 2).toUpperCase(),
description: 'Route type, payload type, version'
});
// Path length byte
const pathLen = decoded?.pathLength ?? serverData.path_len ?? 0;
segments.push({
name: 'Path Length',
startByte: offset + 1,
endByte: offset + 1,
value: '0x' + rawHex.slice((offset + 1) * 2, (offset + 1) * 2 + 2).toUpperCase(),
description: `${pathLen} bytes of path data`
});
// Path data (if present)
if (pathLen > 0) {
segments.push({
name: 'Path',
startByte: offset + 2,
endByte: offset + 1 + pathLen,
value: rawHex.slice((offset + 2) * 2, (offset + 2 + pathLen) * 2).toUpperCase(),
description: 'Route taken through mesh'
});
}
// Payload
const payloadStart = offset + 2 + pathLen;
if (payloadStart < totalBytes) {
const payloadTypeName = decoded?.payloadType !== undefined
? getPayloadTypeName(decoded.payloadType)
: (serverData.payload_type_name || 'Unknown');
segments.push({
name: 'Payload',
startByte: payloadStart,
endByte: totalBytes - 1,
value: rawHex.slice(payloadStart * 2).toUpperCase(),
description: `${payloadTypeName} payload data`
});
}
return { segments };
}
// Populate packet detail modal using decoded data (from meshcore-decoder or server)
async function populatePacketModal(serverData, rawHex, decoded, structure, decodeError) {
// Total bytes
document.getElementById('packet-total-bytes').textContent = `${rawHex.length / 2} bytes`;
// Packet hash - prefer decoder, then server, then compute
let packetHash = decoded?.messageHash || serverData.packet_hash;
if (!packetHash && rawHex && typeof sha256 !== 'undefined') {
const fullHash = sha256(rawHex);
packetHash = fullHash.slice(0, 8).toUpperCase();
}
document.getElementById('detail-packet-hash').textContent = packetHash || 'N/A';
// Timestamp
const timestamp = serverData.datetime
? new Date(serverData.datetime).toLocaleString()
: new Date().toLocaleString();
document.getElementById('detail-timestamp').textContent = timestamp;
// Type and Route - prefer decoder output
const payloadTypeNum = decoded?.payloadType ?? serverData.payload_type;
const payloadType = decoded?.payloadType !== undefined
? getPayloadTypeName(decoded.payloadType)
: (serverData.payload_type_name || getPayloadTypeName(serverData.payload_type));
document.getElementById('detail-payload-type').textContent = payloadType;
const routeType = decoded?.routeType !== undefined
? getRouteTypeName(decoded.routeType)
: (serverData.route_type_name || getRouteTypeName(serverData.route_type));
document.getElementById('detail-route-type').textContent = routeType;
// Path - prefer decoder output
let pathDisplay = 'Direct';
if (decoded?.path && decoded.path.length > 0) {
pathDisplay = decoded.path.join(' → ');
} else if (serverData.path && serverData.path.length > 0) {
pathDisplay = serverData.path.join(' → ');
}
document.getElementById('detail-path').textContent = pathDisplay;
// Hex breakdown with color coding
renderHexBreakdown(rawHex, structure);
// Header analysis
populateHeaderAnalysis(decoded, serverData);
// Path and Payload
populatePathAndPayload(decoded, serverData, structure);
// GroupText breakdown (if applicable)
const grouptextSection = document.getElementById('grouptext-breakdown');
const advertSection = document.getElementById('advert-breakdown');
// Handle payload type as number or string (server may send number or string)
const ptNum = typeof payloadTypeNum === 'string' ? parseInt(payloadTypeNum, 10) : payloadTypeNum;
const isGroupText = ptNum === 5 || payloadTypeNum === 'GRP_TXT' || payloadType === 'GRP_TXT' || payloadType === 'GroupText';
const isAdvert = ptNum === 4 || payloadTypeNum === 'ADVERT' || payloadType === 'Advert' || payloadType === 'ADVERT';
if (isGroupText) {
if (grouptextSection) grouptextSection.classList.add('visible');
if (advertSection) advertSection.classList.remove('show-section');
await populateGroupTextBreakdown(decoded, serverData, structure, rawHex);
} else if (isAdvert) {
if (grouptextSection) grouptextSection.classList.remove('visible');
if (advertSection) {
advertSection.classList.add('show-section');
}
populateAdvertBreakdown(decoded, serverData);
} else {
if (grouptextSection) grouptextSection.classList.remove('visible');
if (advertSection) advertSection.classList.remove('show-section');
}
// Show decoder error if any
const errorDiv = document.getElementById('decoder-error');
if (decodeError) {
errorDiv.style.display = 'block';
document.getElementById('decoder-error-message').textContent = decodeError;
} else {
errorDiv.style.display = 'none';
}
}
// Render color-coded hex breakdown
function renderHexBreakdown(rawHex, structure) {
const container = document.getElementById('hex-breakdown');
container.replaceChildren();
if (!structure || !structure.segments) {
// Simple hex display without structure
container.appendChild(
makeElement('span', 'hex-unknown', formatHexSpaced(rawHex.toUpperCase()))
);
return;
}
// Build color-coded hex display from segments
const hexUpper = rawHex.toUpperCase();
const segments = structure.segments;
// Color mapping for segments
const colorMap = {
'Header': 'hex-header',
'Path Length': 'hex-pathlen',
'Path': 'hex-path',
'Payload': 'hex-payload',
'Transport': 'hex-transport'
};
let currentPos = 0;
for (const segment of segments) {
const startByte = segment.startByte;
const endByte = segment.endByte;
const startHex = startByte * 2;
const endHex = (endByte + 1) * 2;
// Add any gap before this segment
if (currentPos < startHex) {
container.appendChild(
makeElement('span', 'hex-unknown', hexUpper.slice(currentPos, startHex))
);
}
// Add the segment with color
const colorClass = colorMap[segment.name] || 'hex-unknown';
const segmentHex = hexUpper.slice(startHex, endHex);
const segmentElement = makeElement(
'span',
colorClass,
formatHexSpaced(segmentHex)
);
segmentElement.title = `${String(segment.name)}: ${String(segment.description || '')}`;
container.appendChild(segmentElement);
currentPos = endHex;
}
// Add any remaining bytes
if (currentPos < hexUpper.length) {
container.appendChild(
makeElement('span', 'hex-unknown', hexUpper.slice(currentPos))
);
}
}
// Format hex with spaces every 2 characters
function formatHexSpaced(hex) {
return hex.match(/.{1,2}/g)?.join(' ') || hex;
}
// Populate header analysis using decoded data
function populateHeaderAnalysis(decoded, serverData) {
// Get route type, payload type, version - prefer decoder output
const routeType = decoded?.routeType ?? serverData.route_type;
const payloadType = decoded?.payloadType ?? serverData.payload_type;
const version = decoded?.payloadVersion ?? serverData.payload_version;
// Compute header byte from components for display
let headerByte;
if (routeType !== undefined && payloadType !== undefined) {
headerByte = (routeType & 0x03) | ((payloadType & 0x0F) << 2) | (((version ?? 0) & 0x03) << 6);
} else if (serverData.header !== undefined) {
headerByte = typeof serverData.header === 'string'
? parseInt(serverData.header, 16)
: serverData.header;
}
const headerHex = headerByte !== undefined && !isNaN(headerByte)
? `0x${headerByte.toString(16).padStart(2, '0').toUpperCase()}`
: 'Unknown';
document.getElementById('header-value').textContent = headerHex;
// Parse header bits
const tbody = document.getElementById('header-bits-body');
tbody.replaceChildren();
if (routeType !== undefined && payloadType !== undefined) {
const routeTypeName = getRouteTypeName(routeType);
const payloadTypeName = getPayloadTypeName(payloadType);
const appendHeaderRow = (bits, field, value, binary) => {
const row = document.createElement('tr');
row.append(
makeElement('td', '', bits),
makeElement('td', '', field),
makeElement('td', '', value),
makeElement('td', 'text-danger', binary)
);
tbody.appendChild(row);
};
appendHeaderRow('0-1', 'Route Type', routeTypeName, routeType.toString(2).padStart(2, '0'));
appendHeaderRow('2-5', 'Payload Type', payloadTypeName, payloadType.toString(2).padStart(4, '0'));
appendHeaderRow(
'6-7',
'Version',
version ?? '?',
version !== null && version !== undefined
? version.toString(2).padStart(2, '0')
: '??'
);
} else {
const row = document.createElement('tr');
const cell = makeElement('td', '', 'Unable to parse header');
cell.colSpan = 4;
row.appendChild(cell);
tbody.appendChild(row);
}
}
// Populate path and payload sections
function populatePathAndPayload(decoded, serverData, structure) {
// Path length - prefer decoder output
const pathLen = decoded?.pathLength ?? serverData.path_len ?? 0;
document.getElementById('pathlen-value').textContent = `0x${pathLen.toString(16).padStart(2, '0').toUpperCase()}`;
document.getElementById('pathlen-description').textContent =
pathLen > 0
? `${pathLen} bytes showing route taken (increases as packet floods)`
: 'Direct message (no hops)';
// Path data - prefer decoder output
let pathHex = '';
if (decoded?.path && decoded.path.length > 0) {
pathHex = decoded.path.join('');
} else if (serverData.path_hex) {
pathHex = serverData.path_hex;
} else if (serverData.path) {
pathHex = serverData.path.join('');
}
document.getElementById('path-value').textContent = pathHex.toUpperCase() || 'None';
// Calculate byte positions
const hasTransport = decoded?.hasTransport ?? serverData.has_transport_codes ?? false;
const pathStart = hasTransport ? 6 : 2;
const pathEnd = pathStart + pathLen - 1;
document.getElementById('path-bytes').textContent = pathLen > 0
? `Bytes ${pathStart}-${pathEnd}`
: 'N/A';
// Payload - prefer decoder output
const payloadHex = decoded?.payload?.raw || serverData.payload_hex || '';
const payloadStart = pathStart + pathLen;
const totalBytes = window.currentPacketHex ? window.currentPacketHex.length / 2 : 0;
document.getElementById('payload-bytes').textContent = `Bytes ${payloadStart}-${totalBytes - 1}`;
document.getElementById('payload-value').textContent = payloadHex.toUpperCase().slice(0, 100) + (payloadHex.length > 100 ? '...' : '');
const payloadTypeName = decoded?.payloadType !== undefined
? getPayloadTypeName(decoded.payloadType)
: (serverData.payload_type_name || getPayloadTypeName(serverData.payload_type));
document.getElementById('payload-description').textContent = `${payloadTypeName} payload data`;
}
// Populate GroupText breakdown using decoded data
async function populateGroupTextBreakdown(decoded, serverData, structure, fullPacketHex) {
const payloadHex = decoded?.payload?.raw || serverData.payload_hex || '';
const decodedPayload = decoded?.payload?.decoded;
if (payloadHex.length < 6) {
document.getElementById('grouptext-hex').textContent = 'Payload too short for GroupText';
return;
}
// Parse GroupText structure - prefer decoded data
// GroupText format: [channel_hash(1)] [cipher_mac(2)] [ciphertext(N)]
const channelHash = decodedPayload?.channelHash || payloadHex.slice(0, 2).toUpperCase();
const cipherMAC = decodedPayload?.cipherMAC || payloadHex.slice(2, 6).toUpperCase();
const ciphertext = decodedPayload?.ciphertext || payloadHex.slice(6).toUpperCase();
// Channel hash - show name(s) if known
const hashKey = typeof channelHash === 'string' ? channelHash.toUpperCase() : channelHash.toString(16).padStart(2, '0').toUpperCase();
const channelEntries = channelHashMap.get(hashKey) || [];
let channelHashDisplay = `0x${hashKey}`;
if (channelEntries.length === 1) {
channelHashDisplay += ` (${channelEntries[0].name})`;
} else if (channelEntries.length > 1) {
// Multiple channels share this hash
const names = channelEntries.map(e => e.name).join(' or ');
channelHashDisplay += ` (${names})`;
}
document.getElementById('channel-hash-value').textContent = channelHashDisplay;
// Cipher MAC
document.getElementById('cipher-mac-value').textContent = cipherMAC;
// Ciphertext
document.getElementById('ciphertext-value').textContent = ciphertext.slice(0, 80) + (ciphertext.length > 80 ? '...' : '');
document.getElementById('ciphertext-bytes').textContent = `Bytes 3-${2 + (ciphertext.length / 2)}`;
// Render GroupText hex with color coding
renderGroupTextHex(payloadHex);
// Decrypted message - check if decoder decrypted it
const decryptedSection = document.getElementById('decrypted-section');
if (decodedPayload?.decrypted) {
decryptedSection.style.display = 'block';
const decrypted = decodedPayload.decrypted;
document.getElementById('decrypted-sender').textContent = decrypted.sender || decrypted.senderPubKeyPrefix || 'Unknown';
document.getElementById('decrypted-timestamp').textContent = decrypted.timestamp
? new Date(decrypted.timestamp * 1000).toLocaleString()
: 'Unknown';
// Look up channel name - prefer decoder's key info, then verify by trying decryption
const hashKey = typeof channelHash === 'string' ? channelHash.toUpperCase() : channelHash.toString(16).padStart(2, '0').toUpperCase();
let resolvedChannelName = decrypted.channelName;
if (!resolvedChannelName) {
// Look up by hash, try decryption if multiple candidates
const channelEntries = channelHashMap.get(hashKey) || [];
if (channelEntries.length === 1) {
resolvedChannelName = channelEntries[0].name;
} else if (channelEntries.length > 1 && fullPacketHex) {
// Multiple candidates - try decrypting with each to find the right one
resolvedChannelName = await identifyChannelByDecryption(channelEntries, fullPacketHex);
} else if (channelEntries.length > 1) {
resolvedChannelName = channelEntries.map(e => e.name).join(' or ');
} else {
resolvedChannelName = `Unknown (Hash: ${hashKey})`;
}
}
document.getElementById('decrypted-channel').textContent = resolvedChannelName;
document.getElementById('decrypted-message').textContent = decrypted.message || decrypted.text || '';
} else if (serverData.decrypted_message) {
// Fallback to server-provided decryption
decryptedSection.style.display = 'block';
document.getElementById('decrypted-sender').textContent = serverData.decrypted_sender || 'Unknown';
document.getElementById('decrypted-timestamp').textContent = serverData.decrypted_timestamp
? new Date(serverData.decrypted_timestamp * 1000).toLocaleString()
: 'Unknown';
document.getElementById('decrypted-channel').textContent = serverData.decrypted_channel || 'Unknown';
document.getElementById('decrypted-message').textContent = serverData.decrypted_message;
} else {
decryptedSection.style.display = 'none';
}
}
// Render GroupText hex with color coding
function renderGroupTextHex(payloadHex) {
const container = document.getElementById('grouptext-hex');
const hex = payloadHex.toUpperCase();
if (hex.length < 6) {
container.textContent = hex;
return;
}
// Color-code: Channel Hash (0-1), MAC (2-5), Ciphertext (6+)
const channelHash = hex.slice(0, 2);
const mac = hex.slice(2, 6);
const ciphertext = hex.slice(6);
const channelElement = makeElement('span', 'hex-channel-hash', channelHash);
channelElement.title = 'Channel Hash';
const macElement = makeElement('span', 'hex-cipher-mac', formatHexSpaced(mac));
macElement.title = 'Cipher MAC';
const ciphertextElement = makeElement(
'span',
'hex-ciphertext',
formatHexSpaced(ciphertext)
);
ciphertextElement.title = 'Ciphertext';
container.replaceChildren(
channelElement,
document.createTextNode(' '),
macElement,
document.createTextNode(' '),
ciphertextElement
);
}
// Populate Advert breakdown using decoded data
function populateAdvertBreakdown(decoded, serverData) {
const decodedPayload = decoded?.payload?.decoded;
const payloadHex = decoded?.payload?.raw || serverData.payload_hex || '';
// Public key - prefer decoded data
const pubkey = decodedPayload?.publicKey ||
(payloadHex.length >= 64 ? payloadHex.slice(0, 64).toUpperCase() : null) ||
serverData.advert_public_key ||
'Unknown';
document.getElementById('advert-pubkey').textContent = pubkey;
// Node info - prefer decoded data
const nodeInfo = document.getElementById('advert-node-info');
const appData = decodedPayload?.appData;
const name = appData?.name || serverData.advert_name || 'Unknown';
const role = appData?.deviceRole !== undefined
? getDeviceRoleName(appData.deviceRole)
: (serverData.advert_mode || 'Unknown');
const appendNodeInfo = (label, value) => {
const line = document.createElement('div');
line.append(
makeElement('strong', '', `${label}:`),
document.createTextNode(` ${String(value)}`)
);
nodeInfo.appendChild(line);
};
nodeInfo.replaceChildren();
appendNodeInfo('Name', name);
appendNodeInfo('Role', role);
// Location
if (appData?.location) {
appendNodeInfo(
'Location',
`${appData.location.latitude.toFixed(4)}, ${appData.location.longitude.toFixed(4)}`
);
} else if (serverData.advert_latitude !== undefined && serverData.advert_longitude !== undefined) {
appendNodeInfo(
'Location',
`${serverData.advert_latitude.toFixed(4)}, ${serverData.advert_longitude.toFixed(4)}`
);
}
// Timestamp from decoded advert or server
if (decodedPayload?.timestamp) {
appendNodeInfo(
'Advert Time',
new Date(decodedPayload.timestamp * 1000).toLocaleString()
);
} else if (serverData.advert_timestamp) {
appendNodeInfo(
'Advert Time',
new Date(serverData.advert_timestamp * 1000).toLocaleString()
);
}
}
// Helper: Get payload type name
function getPayloadTypeName(type) {
const names = {
0: 'Request',
1: 'Response',
2: 'PlainText',
3: 'Ack',
4: 'Advert',
5: 'GroupText',
6: 'GroupDatagram',
7: 'AnonRequest',
8: 'ReturnedPath',
9: 'Trace',
10: 'MultiPart',
15: 'Custom'
};
return names[type] || `Type${type}`;
}
// Helper: Get route type name
function getRouteTypeName(type) {
const names = {
0: 'Direct',
1: 'Flood',
2: 'TransportFlood',
3: 'TransportDirect'
};
return names[type] || `Route${type}`;
}
// Helper: Get device role name
function getDeviceRoleName(role) {
const names = {
0: 'Client',
1: 'Companion',
2: 'Repeater',
3: 'RoomServer'
};
return names[role] || `Role${role}`;
}
// Copy raw packet hex to clipboard
function copyRawPacket() {
const hex = window.currentPacketHex || currentPacketHex;
if (hex) {
navigator.clipboard.writeText(hex).then(() => {
// Show brief feedback
const btn = document.getElementById('copy-raw-packet');
setButtonIconAndLabel(btn, 'fas fa-check', 'Copied!');
setTimeout(() => {
setButtonIconAndLabel(btn, 'fas fa-copy', 'Copy Raw Hex');
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
}
function clearCommands() {
renderWaitingState(
document.getElementById('command-stream'),
'Waiting for command data...'
);
}
function clearPackets() {
renderWaitingState(
document.getElementById('packet-stream'),
'Waiting for packet data...'
);
packetStore.clear();
packetCounter = 0;
}
document.getElementById('cmd-scroll-top').addEventListener(
'click', () => scrollStream('command-stream', 'top')
);
document.getElementById('cmd-scroll-bottom').addEventListener(
'click', () => scrollStream('command-stream', 'bottom')
);
document.getElementById('cmd-clear').addEventListener('click', clearCommands);
document.getElementById('msg-scroll-top').addEventListener(
'click', () => scrollStream('message-stream', 'top')
);
document.getElementById('msg-scroll-bottom').addEventListener(
'click', () => scrollStream('message-stream', 'bottom')
);
document.getElementById('msg-clear').addEventListener('click', clearMessages);
document.getElementById('msg-pause-btn').addEventListener('click', toggleMessagePause);
document.getElementById('pkt-scroll-top').addEventListener(
'click', () => scrollStream('packet-stream', 'top')
);
document.getElementById('pkt-scroll-bottom').addEventListener(
'click', () => scrollStream('packet-stream', 'bottom')
);
document.getElementById('pkt-clear').addEventListener('click', clearPackets);
document.getElementById('copy-raw-packet').addEventListener('click', copyRawPacket);
</script>
{% endblock %}