mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-06 07:45:24 +00:00
2215 lines
92 KiB
HTML
2215 lines
92 KiB
HTML
{% 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; MD–LG = 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" onclick="scrollStream('command-stream','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" onclick="scrollStream('command-stream','bottom')" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
|
||
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="clearCommands()">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" onclick="scrollStream('message-stream','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" onclick="scrollStream('message-stream','bottom')" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
|
||
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="clearMessages()">Clear</button>
|
||
<button class="btn btn-sm btn-outline-secondary ms-1" id="msg-pause-btn" onclick="toggleMessagePause()">
|
||
<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" onclick="scrollStream('packet-stream','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" onclick="scrollStream('packet-stream','bottom')" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
|
||
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="clearPackets()">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" onclick="copyRawPacket()">
|
||
<i class="fas fa-copy"></i> Copy Raw Hex
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// Load meshcore-decoder asynchronously so it doesn't block socket init.
|
||
// Dynamic import() works in regular <script> tags on all modern browsers and
|
||
// avoids the <script type="module"> scope/timing issues that 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 toggleMessagePause() {
|
||
messagePaused = !messagePaused;
|
||
const icon = document.getElementById('msg-pause-icon');
|
||
const btn = document.getElementById('msg-pause-btn');
|
||
if (messagePaused) {
|
||
icon.className = 'fas fa-play';
|
||
btn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||
updateStatus('message-status', 'Paused', 'warning');
|
||
} else {
|
||
icon.className = 'fas fa-pause';
|
||
btn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||
updateStatus('message-status', 'Active', 'success');
|
||
}
|
||
}
|
||
|
||
function clearMessages() {
|
||
document.getElementById('message-stream').innerHTML =
|
||
'<div class="text-muted text-center py-3"><i class="fas fa-hourglass-half"></i> Waiting for channel messages…</div>';
|
||
}
|
||
|
||
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 0–359: 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 ch = '';
|
||
if (data.channel) {
|
||
ch = '<span class="badge live-msg-channel-badge me-1" style="background-color:' + accent.badgeBg + ';color:' + accent.badgeFg + ';">' +
|
||
escapeHtml(data.channel) + '</span>';
|
||
}
|
||
const dm = isDm ? '<span class="badge bg-secondary me-1">DM</span>' : '';
|
||
|
||
const snr = (data.snr && data.snr !== 'unknown')
|
||
? '<span class="badge rounded-pill bg-primary live-msg-meta-badge">SNR ' + escapeHtml(String(data.snr)) + '</span>'
|
||
: '';
|
||
|
||
// 255 = unknown hop count from radio; omit badge (same as before).
|
||
let hopsHtml = '';
|
||
const h = data.hops;
|
||
if (h != null && h !== '' && h !== 255) {
|
||
if (h === 0) {
|
||
hopsHtml = '<span class="badge rounded-pill bg-info text-dark live-msg-meta-badge">Direct</span>';
|
||
} else {
|
||
hopsHtml = '<span class="badge rounded-pill bg-info text-dark live-msg-meta-badge">' +
|
||
escapeHtml(String(h)) + ' hop' + (h !== 1 ? 's' : '') + '</span>';
|
||
}
|
||
}
|
||
|
||
const badgesRow = (snr || hopsHtml)
|
||
? '<div class="live-msg-meta-badges-row">' + snr + hopsHtml + '</div>'
|
||
: '';
|
||
|
||
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';
|
||
entry.innerHTML = `
|
||
<div class="d-flex justify-content-between align-items-start mb-1 flex-wrap gap-2 w-100">
|
||
<span class="d-flex flex-wrap align-items-center gap-1 min-w-0">${ch}${dm}<strong>${escapeHtml(data.sender || '?')}</strong></span>
|
||
<div class="live-msg-meta-stack ms-auto flex-shrink-0 text-end">
|
||
${badgesRow}
|
||
<small class="text-muted live-msg-time">${ts}</small>
|
||
</div>
|
||
</div>
|
||
<div class="text-break">${escapeHtml(bodyRaw)}</div>
|
||
`;
|
||
|
||
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 escapeHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
window.clearMessages = clearMessages;
|
||
window.toggleMessagePause = toggleMessagePause;
|
||
|
||
window.scrollStream = function(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 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 existingEntry = container.querySelector(`[data-command-id="${data.command_id}"]`);
|
||
if (existingEntry) {
|
||
// Update existing entry with new repeat information
|
||
const botMessage = existingEntry.querySelector('.bot-message');
|
||
if (botMessage) {
|
||
const responseText = data.response || 'No response';
|
||
const responseClass = data.success ? 'success' : 'danger';
|
||
const botMessageClass = data.success ? 'bot-message-success' : 'bot-message-danger';
|
||
|
||
// Display repeat information if available
|
||
const repeatCount = data.repeat_count || 0;
|
||
const repeaterPrefixes = data.repeater_prefixes || [];
|
||
const repeaterCounts = data.repeater_counts || {};
|
||
let repeatInfo = '';
|
||
if (repeatCount > 0) {
|
||
let tooltipText = '';
|
||
if (repeaterPrefixes.length > 0) {
|
||
// Build detailed tooltip with counts per repeater
|
||
const prefixDetails = repeaterPrefixes.map(prefix => {
|
||
const count = 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)';
|
||
}
|
||
repeatInfo = `<div class="small text-info mt-1">
|
||
<i class="fas fa-broadcast-tower"></i> Heard <span class="text-decoration-underline" style="cursor: help;" data-bs-toggle="tooltip" data-bs-placement="top" title="${escapeHtml(tooltipText)}">${repeatCount} repeat${repeatCount !== 1 ? 's' : ''}</span>
|
||
</div>`;
|
||
}
|
||
|
||
botMessage.innerHTML = `
|
||
<div class="d-flex align-items-center mb-1">
|
||
<strong class="text-success me-2">🤖 Bot</strong>
|
||
<span class="badge bg-${responseClass} small me-2">${data.success ? 'Success' : 'Failed'}</span>
|
||
<small class="text-muted ms-auto">${data.timestamp ? new Date(data.timestamp * 1000).toLocaleTimeString() : new Date().toLocaleTimeString()}</small>
|
||
</div>
|
||
<div class="response-text">${escapeHtml(responseText)}</div>
|
||
${repeatInfo}
|
||
`;
|
||
|
||
// Initialize Bootstrap tooltips for repeat counts in updated entry
|
||
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
||
const tooltipElements = botMessage.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||
tooltipElements.forEach(el => {
|
||
// Destroy existing tooltip if any
|
||
const existingTooltip = bootstrap.Tooltip.getInstance(el);
|
||
if (existingTooltip) {
|
||
existingTooltip.dispose();
|
||
}
|
||
// Create new tooltip
|
||
new bootstrap.Tooltip(el);
|
||
});
|
||
}
|
||
}
|
||
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.setAttribute('data-command-id', data.command_id);
|
||
}
|
||
|
||
// User command message
|
||
const userMessage = document.createElement('div');
|
||
userMessage.className = 'd-flex justify-content-start mb-2';
|
||
userMessage.innerHTML = `
|
||
<div class="user-message p-2 rounded" style="max-width: 80%;">
|
||
<div class="d-flex align-items-center mb-1">
|
||
<strong class="text-primary me-2">${data.user || 'Unknown'}</strong>
|
||
<small class="text-muted me-2">${data.channel || 'Unknown'}</small>
|
||
<small class="text-muted ms-auto">${data.timestamp ? new Date(data.timestamp * 1000).toLocaleTimeString() : new Date().toLocaleTimeString()}</small>
|
||
</div>
|
||
<div class="fw-bold">${escapeHtml(data.user_input || `/${data.command || 'Unknown Command'}`)}</div>
|
||
</div>
|
||
`;
|
||
|
||
// Bot response message
|
||
const botMessage = document.createElement('div');
|
||
botMessage.className = 'd-flex justify-content-end mb-2';
|
||
const responseText = data.response || 'No response';
|
||
const responseClass = data.success ? 'success' : 'danger';
|
||
const botMessageClass = data.success ? 'bot-message-success' : 'bot-message-danger';
|
||
|
||
// Display repeat information if available
|
||
const repeatCount = data.repeat_count || 0;
|
||
const repeaterPrefixes = data.repeater_prefixes || [];
|
||
const repeaterCounts = data.repeater_counts || {};
|
||
let repeatInfo = '';
|
||
if (repeatCount > 0) {
|
||
let tooltipText = '';
|
||
if (repeaterPrefixes.length > 0) {
|
||
// Build detailed tooltip with counts per repeater
|
||
const prefixDetails = repeaterPrefixes.map(prefix => {
|
||
const count = 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)';
|
||
}
|
||
repeatInfo = `<div class="small text-info mt-1">
|
||
<i class="fas fa-broadcast-tower"></i> Heard <span class="text-decoration-underline" style="cursor: help;" data-bs-toggle="tooltip" data-bs-placement="top" title="${escapeHtml(tooltipText)}">${repeatCount} repeat${repeatCount !== 1 ? 's' : ''}</span>
|
||
</div>`;
|
||
}
|
||
|
||
botMessage.innerHTML = `
|
||
<div class="bot-message p-2 rounded ${botMessageClass}" style="max-width: 80%;">
|
||
<div class="d-flex align-items-center mb-1">
|
||
<strong class="text-success me-2">🤖 Bot</strong>
|
||
<span class="badge bg-${responseClass} small me-2">${data.success ? 'Success' : 'Failed'}</span>
|
||
<small class="text-muted ms-auto">${data.timestamp ? new Date(data.timestamp * 1000).toLocaleTimeString() : new Date().toLocaleTimeString()}</small>
|
||
</div>
|
||
<div class="response-text">${escapeHtml(responseText)}</div>
|
||
${repeatInfo}
|
||
</div>
|
||
`;
|
||
|
||
entry.appendChild(userMessage);
|
||
entry.appendChild(botMessage);
|
||
container.insertBefore(entry, container.firstChild);
|
||
|
||
// Initialize Bootstrap tooltips for repeat counts
|
||
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
||
const tooltipElements = entry.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||
tooltipElements.forEach(el => {
|
||
// Destroy existing tooltip if any
|
||
const existingTooltip = bootstrap.Tooltip.getInstance(el);
|
||
if (existingTooltip) {
|
||
existingTooltip.dispose();
|
||
}
|
||
// Create new tooltip
|
||
new bootstrap.Tooltip(el);
|
||
});
|
||
}
|
||
|
||
// 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 headerInfo = '';
|
||
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?';
|
||
headerInfo = `<div class="small text-muted">Header: ${data.header} (${routeName} (${routeValue}) | ${payloadName} (${payloadValue}) | ${version})</div>`;
|
||
}
|
||
|
||
// Format transport info (only show if present)
|
||
const transportInfo = data.has_transport_codes && data.transport_codes ?
|
||
`<div class="small text-warning">Transport: ${data.transport_codes.hex || 'Unknown'}</div>` : '';
|
||
|
||
// Format payload info (only show if present)
|
||
const payloadInfo = data.payload_bytes > 0 ?
|
||
`<div class="small text-muted">Payload: ${data.payload_bytes} bytes</div>` : '';
|
||
|
||
// Format advert info (only show if this is an ADVERT packet with advert data)
|
||
const advertInfo = data.advert_name && data.payload_type_name === 'ADVERT' ?
|
||
`<div class="small text-success"><strong>Advertised:</strong> ${escapeHtml(data.advert_name)} (${data.advert_mode || 'Unknown'})</div>` : '';
|
||
|
||
// Check if raw packet hex is available for detailed view
|
||
const hasRawHex = !!(data.raw_packet_hex || data.payload_hex);
|
||
const clickableClass = hasRawHex ? 'packet-clickable' : '';
|
||
const clickHint = hasRawHex ? '<span class="click-hint"><i class="fas fa-search-plus"></i></span>' : '';
|
||
|
||
// 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.onclick = () => 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';
|
||
|
||
entry.innerHTML = `
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="flex-grow-1">
|
||
<div class="d-flex align-items-center mb-2">
|
||
<div class="fw-bold text-info me-2">${data.route_type_name || data.route_type || 'Unknown Route'}</div>
|
||
<span class="badge ${badgeColor}">${data.payload_type_name || data.payload_type || 'Unknown'}</span>
|
||
${data.payload_version !== undefined ? `<span class="badge bg-light text-dark ms-1">v${data.payload_version}</span>` : ''}
|
||
${clickHint}
|
||
</div>
|
||
|
||
<div class="small text-muted mb-1 packet-stream-path">
|
||
<strong>Path:</strong> ${pathDisplay}
|
||
${data.path_len > 0 ? ` (${data.path_len} bytes)` : ''}
|
||
</div>
|
||
|
||
${advertInfo}
|
||
${headerInfo}
|
||
${transportInfo}
|
||
${payloadInfo}
|
||
</div>
|
||
|
||
<div class="text-end">
|
||
<div class="small text-muted mb-1">${timestamp}</div>
|
||
<div class="d-flex flex-column align-items-end">
|
||
<span class="badge bg-info mb-1">${data.hops || data.path_len || 0} hops</span>
|
||
${data.has_transport_codes ? '<span class="badge bg-warning small">Transport</span>' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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');
|
||
|
||
if (!structure || !structure.segments) {
|
||
// Simple hex display without structure
|
||
container.innerHTML = formatHexSimple(rawHex);
|
||
return;
|
||
}
|
||
|
||
// Build color-coded hex display from segments
|
||
let html = '';
|
||
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) {
|
||
html += `<span class="hex-unknown">${hexUpper.slice(currentPos, startHex)}</span>`;
|
||
}
|
||
|
||
// Add the segment with color
|
||
const colorClass = colorMap[segment.name] || 'hex-unknown';
|
||
const segmentHex = hexUpper.slice(startHex, endHex);
|
||
html += `<span class="${colorClass}" title="${segment.name}: ${segment.description || ''}">${formatHexSpaced(segmentHex)}</span>`;
|
||
|
||
currentPos = endHex;
|
||
}
|
||
|
||
// Add any remaining bytes
|
||
if (currentPos < hexUpper.length) {
|
||
html += `<span class="hex-unknown">${hexUpper.slice(currentPos)}</span>`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// Format hex with spaces every 2 characters
|
||
function formatHexSpaced(hex) {
|
||
return hex.match(/.{1,2}/g)?.join(' ') || hex;
|
||
}
|
||
|
||
// Simple hex format
|
||
function formatHexSimple(hex) {
|
||
const upper = hex.toUpperCase();
|
||
return `<span class="hex-unknown">${formatHexSpaced(upper)}</span>`;
|
||
}
|
||
|
||
// 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');
|
||
|
||
if (routeType !== undefined && payloadType !== undefined) {
|
||
const routeTypeName = getRouteTypeName(routeType);
|
||
const payloadTypeName = getPayloadTypeName(payloadType);
|
||
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td>0-1</td>
|
||
<td>Route Type</td>
|
||
<td>${routeTypeName}</td>
|
||
<td class="text-danger">${(routeType).toString(2).padStart(2, '0')}</td>
|
||
</tr>
|
||
<tr>
|
||
<td>2-5</td>
|
||
<td>Payload Type</td>
|
||
<td>${payloadTypeName}</td>
|
||
<td class="text-danger">${(payloadType).toString(2).padStart(4, '0')}</td>
|
||
</tr>
|
||
<tr>
|
||
<td>6-7</td>
|
||
<td>Version</td>
|
||
<td>${version ?? '?'}</td>
|
||
<td class="text-danger">${version !== null && version !== undefined ? version.toString(2).padStart(2, '0') : '??'}</td>
|
||
</tr>
|
||
`;
|
||
} else {
|
||
tbody.innerHTML = '<tr><td colspan="4">Unable to parse header</td></tr>';
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
|
||
container.innerHTML = `<span class="hex-channel-hash" title="Channel Hash">${channelHash}</span> ` +
|
||
`<span class="hex-cipher-mac" title="Cipher MAC">${formatHexSpaced(mac)}</span> ` +
|
||
`<span class="hex-ciphertext" title="Ciphertext">${formatHexSpaced(ciphertext)}</span>`;
|
||
}
|
||
|
||
// 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');
|
||
|
||
// Location
|
||
let locationHtml = '';
|
||
if (appData?.location) {
|
||
locationHtml = `<div><strong>Location:</strong> ${appData.location.latitude.toFixed(4)}, ${appData.location.longitude.toFixed(4)}</div>`;
|
||
} else if (serverData.advert_latitude !== undefined && serverData.advert_longitude !== undefined) {
|
||
locationHtml = `<div><strong>Location:</strong> ${serverData.advert_latitude.toFixed(4)}, ${serverData.advert_longitude.toFixed(4)}</div>`;
|
||
}
|
||
|
||
// Timestamp from decoded advert or server
|
||
let timestampHtml = '';
|
||
if (decodedPayload?.timestamp) {
|
||
timestampHtml = `<div><strong>Advert Time:</strong> ${new Date(decodedPayload.timestamp * 1000).toLocaleString()}</div>`;
|
||
} else if (serverData.advert_timestamp) {
|
||
timestampHtml = `<div><strong>Advert Time:</strong> ${new Date(serverData.advert_timestamp * 1000).toLocaleString()}</div>`;
|
||
}
|
||
|
||
nodeInfo.innerHTML = `
|
||
<div><strong>Name:</strong> ${escapeHtml(name)}</div>
|
||
<div><strong>Role:</strong> ${role}</div>
|
||
${locationHtml}
|
||
${timestampHtml}
|
||
`;
|
||
}
|
||
|
||
// 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}`;
|
||
}
|
||
|
||
// Helper: Escape HTML
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 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.querySelector('[onclick="copyRawPacket()"]');
|
||
const originalText = btn.innerHTML;
|
||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||
setTimeout(() => {
|
||
btn.innerHTML = originalText;
|
||
}, 1500);
|
||
}).catch(err => {
|
||
console.error('Failed to copy:', err);
|
||
});
|
||
}
|
||
}
|
||
|
||
function clearCommands() {
|
||
document.getElementById('command-stream').innerHTML = `
|
||
<div class="text-muted text-center py-3">
|
||
<i class="fas fa-hourglass-half"></i> Waiting for command data...
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function clearPackets() {
|
||
document.getElementById('packet-stream').innerHTML = `
|
||
<div class="text-muted text-center py-3">
|
||
<i class="fas fa-hourglass-half"></i> Waiting for packet data...
|
||
</div>
|
||
`;
|
||
packetStore.clear();
|
||
packetCounter = 0;
|
||
}
|
||
|
||
// Export functions to global scope for onclick handlers
|
||
window.clearCommands = clearCommands;
|
||
window.clearPackets = clearPackets;
|
||
window.copyRawPacket = copyRawPacket;
|
||
</script>
|
||
{% endblock %}
|