mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-04 03:52:34 +00:00
The replay on load is the newest 50 messages and the newest 50 packets, and packets reach back far less, so older message and command rows had a hash and no packet. Opening such a row now asks GET /api/packets/by-hash/<hash>?near=<epoch> for that packet's stored receptions and fills in route, path, receptions and Inspect bytes. The near time bounds the scan to the timestamp index, since the hash is only inside the row's JSON (about 6 ms against a full scan's 250 ms on 90k rows). The fetched receptions fill the row only: they stay out of the spark and the replay's coverage, since they aren't new hearings. The detail says Loading while it waits, that the packet isn't in the bot's log when nothing comes back, or that it couldn't load and reopening retries.
3654 lines
140 KiB
HTML
3654 lines
140 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Real-time - MeshCore Bot Data Viewer{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
/* ── Receiver tape ──────────────────────────────────────────────────────
|
||
One row per transmission the radio heard. Colors come from the
|
||
base.html tokens; the only page-local tokens are the route data colors
|
||
(DESIGN.md: flood blue, direct teal) and the fresh-row wash. */
|
||
:root {
|
||
--rt-route-flood: #0d6efd;
|
||
--rt-route-direct: #20c997;
|
||
--rt-fresh: rgba(13, 110, 253, 0.1);
|
||
--rt-row-hover: var(--bg-secondary);
|
||
/* The bot's own voice: one violet, kept clear of the packet-type hues.
|
||
Fill and text are separate (Fill-Is-Not-Text). */
|
||
--rt-bot: #7c3aed;
|
||
--rt-bot-on: #ffffff;
|
||
--rt-bot-text: #6d28d9;
|
||
--rt-bot-wash: rgba(124, 58, 237, 0.07);
|
||
--rt-bot-rule: rgba(124, 58, 237, 0.45);
|
||
--rt-type: #a8a29e;
|
||
--rt-type-mix: black;
|
||
--rt-lens-all: #0d6efd;
|
||
--rt-lens-message: #2a78d6;
|
||
--rt-lens-on: #ffffff;
|
||
}
|
||
|
||
[data-theme="dark"] {
|
||
--rt-route-flood: #6ea8fe;
|
||
--rt-route-direct: #3dd5b0;
|
||
--rt-fresh: rgba(110, 168, 254, 0.16);
|
||
--rt-row-hover: var(--bg-tertiary);
|
||
--rt-bot: #a78bfa;
|
||
--rt-bot-on: #1a1a1a;
|
||
--rt-bot-text: #c4b5fd;
|
||
--rt-bot-wash: rgba(167, 139, 250, 0.1);
|
||
--rt-bot-rule: rgba(167, 139, 250, 0.5);
|
||
--rt-type: #87817c;
|
||
--rt-type-mix: white;
|
||
--rt-lens-all: #6ea8fe;
|
||
--rt-lens-message: #3987e5;
|
||
--rt-lens-on: #1a1a1a;
|
||
}
|
||
|
||
/* Packet types take the dashboard's packet-type chart colors (SERIES_PALETTE
|
||
in dashboard.js, PACKET_ENCODING_TYPES order), so a type reads the same on
|
||
both pages. Each type also has its own icon; color is never the only cue. */
|
||
.rt-t-grp_txt { --rt-type: #2a78d6; }
|
||
.rt-t-response { --rt-type: #eb6834; }
|
||
.rt-t-req { --rt-type: #1baf7a; }
|
||
.rt-t-path { --rt-type: #eda100; }
|
||
.rt-t-txt_msg { --rt-type: #e87ba4; }
|
||
.rt-t-anon_req { --rt-type: #008300; }
|
||
.rt-t-grp_data { --rt-type: #4a3aa7; }
|
||
.rt-t-advert { --rt-type: #e34948; }
|
||
.rt-t-other { --rt-type: #a8a29e; }
|
||
|
||
[data-theme="dark"] .rt-t-grp_txt { --rt-type: #3987e5; }
|
||
[data-theme="dark"] .rt-t-response { --rt-type: #d95926; }
|
||
[data-theme="dark"] .rt-t-req { --rt-type: #199e70; }
|
||
[data-theme="dark"] .rt-t-path { --rt-type: #c98500; }
|
||
[data-theme="dark"] .rt-t-txt_msg { --rt-type: #d55181; }
|
||
[data-theme="dark"] .rt-t-anon_req { --rt-type: #008300; }
|
||
[data-theme="dark"] .rt-t-grp_data { --rt-type: #9085e9; }
|
||
[data-theme="dark"] .rt-t-advert { --rt-type: #e66767; }
|
||
[data-theme="dark"] .rt-t-other { --rt-type: #87817c; }
|
||
|
||
/* Glyph chips: packets are a tinted icon, decoded messages a stronger
|
||
tint, and the bot's commands a solid violet chip, loudest of the three. */
|
||
.rt-glyph-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 1.5rem;
|
||
height: 1.5rem;
|
||
border-radius: 0.375rem;
|
||
font-size: 0.7rem;
|
||
color: color-mix(in oklch, var(--rt-type) 72%, var(--rt-type-mix));
|
||
background-color: color-mix(in srgb, var(--rt-type) 12%, transparent);
|
||
}
|
||
|
||
.rt-glyph-chip.is-message {
|
||
background-color: color-mix(in srgb, var(--rt-type) 24%, transparent);
|
||
}
|
||
|
||
.rt-glyph-chip.is-bot {
|
||
color: var(--rt-bot-on);
|
||
background-color: var(--rt-bot);
|
||
}
|
||
|
||
.rt-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.rt-head h1 {
|
||
margin: 0;
|
||
}
|
||
|
||
/* Receiver strip: the dashboard health strip, pinned while the tape scrolls. */
|
||
.rt-strip {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.25rem 1.5rem;
|
||
min-height: 56px;
|
||
padding: 0.5rem 1rem;
|
||
margin-bottom: 0.75rem;
|
||
background-color: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 10px;
|
||
box-shadow: 0 1px 3px var(--shadow-color);
|
||
}
|
||
|
||
.rt-strip__item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
font-size: 0.875rem;
|
||
color: var(--text-color);
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.rt-strip__item .status-indicator {
|
||
margin-right: 0;
|
||
}
|
||
|
||
.rt-strip__label {
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.rt-strip__spacer {
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
/* Channel dots: 20 colors, ten hues kept clear of the status lamps' red,
|
||
amber and green, each at two lightness steps. Every one holds 3:1 on the
|
||
card in its theme (4.2:1 light, 5.2:1 dark); the channel name beside the
|
||
dot carries the identity, so a shared color between two channels is
|
||
harmless. */
|
||
:root {
|
||
--ch-0: #af5a21;
|
||
--ch-1: #737c00;
|
||
--ch-2: #008c7e;
|
||
--ch-3: #00879e;
|
||
--ch-4: #007eb5;
|
||
--ch-5: #4573c0;
|
||
--ch-6: #6e67bd;
|
||
--ch-7: #8c5cad;
|
||
--ch-8: #a25492;
|
||
--ch-9: #af5074;
|
||
--ch-10: #883600;
|
||
--ch-11: #515900;
|
||
--ch-12: #00685c;
|
||
--ch-13: #00637a;
|
||
--ch-14: #005b8f;
|
||
--ch-15: #234f99;
|
||
--ch-16: #4d4496;
|
||
--ch-17: #683987;
|
||
--ch-18: #7c316e;
|
||
--ch-19: #872c52;
|
||
}
|
||
[data-theme="dark"] {
|
||
--ch-0: #e08d5d;
|
||
--ch-1: #a2ad4f;
|
||
--ch-2: #26bdae;
|
||
--ch-3: #1db8ce;
|
||
--ch-4: #4cb0e5;
|
||
--ch-5: #78a5ef;
|
||
--ch-6: #9d99ed;
|
||
--ch-7: #bc8fdd;
|
||
--ch-8: #d387c2;
|
||
--ch-9: #e183a4;
|
||
--ch-10: #ffb382;
|
||
--ch-11: #c8d475;
|
||
--ch-12: #5ce4d5;
|
||
--ch-13: #58e0f6;
|
||
--ch-14: #75d7ff;
|
||
--ch-15: #9cccff;
|
||
--ch-16: #c2bfff;
|
||
--ch-17: #e3b4ff;
|
||
--ch-18: #fbacea;
|
||
--ch-19: #ffa9ca;
|
||
}
|
||
|
||
/* The radio's own RX LED: dark until a packet arrives, lit while they do.
|
||
It means "receiving", so it borrows the connected lamp's green. */
|
||
.rt-rx {
|
||
gap: 0.3rem;
|
||
}
|
||
.rt-rx__led {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
border: 1px solid color-mix(in srgb, #198754 55%, transparent);
|
||
background: transparent;
|
||
transition: background-color 160ms ease-out;
|
||
}
|
||
.rt-rx__led.is-on {
|
||
background: #198754;
|
||
transition: none;
|
||
}
|
||
[data-theme="dark"] .rt-rx__led {
|
||
border-color: color-mix(in srgb, #3fbf7f 55%, transparent);
|
||
}
|
||
[data-theme="dark"] .rt-rx__led.is-on {
|
||
background: #3fbf7f;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.rt-rx__led { transition: none; }
|
||
}
|
||
|
||
.rt-spark {
|
||
display: block;
|
||
width: 120px;
|
||
height: 24px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.rt-spark rect {
|
||
fill: var(--rt-route-flood);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.rt-spark rect.is-now {
|
||
opacity: 1;
|
||
}
|
||
|
||
.rt-spark rect.is-unknown {
|
||
opacity: 0.25;
|
||
}
|
||
|
||
.rt-pending {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* Filter bar */
|
||
.rt-filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.rt-filters .form-select,
|
||
.rt-filters .form-control {
|
||
width: auto;
|
||
min-width: 9rem;
|
||
}
|
||
|
||
.rt-filters .rt-search {
|
||
flex: 1 1 14rem;
|
||
max-width: 22rem;
|
||
}
|
||
|
||
.rt-filter-count {
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.rt-filters-toggle {
|
||
display: none;
|
||
}
|
||
|
||
/* Tape */
|
||
.rt-tape,
|
||
.rt-kept-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
background-color: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 0.375rem;
|
||
box-shadow: 0 2px 4px var(--shadow-color);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.rt-kept {
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.rt-kept-title,
|
||
.rt-subhead,
|
||
.rt-divider {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.rt-kept-title {
|
||
margin: 0 0 0.35rem;
|
||
}
|
||
|
||
.rt-subhead {
|
||
margin: 0.5rem 0 0.25rem;
|
||
}
|
||
|
||
.rt-divider {
|
||
padding: 0.45rem 0.75rem;
|
||
background-color: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
/* Link and radio events on the tape: a lamp dot says how it turned out
|
||
(green recovered, amber something missed, gray a covered blip). */
|
||
.rt-marker {
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
font-size: 0.75rem;
|
||
}
|
||
.rt-marker::before {
|
||
content: "";
|
||
flex: none;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--text-muted);
|
||
}
|
||
.rt-marker--recovered::before { background: #198754; }
|
||
.rt-marker--gap::before { background: #ffc107; }
|
||
|
||
.rt-row {
|
||
position: relative;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.rt-row:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
|
||
.rt-row.is-fresh {
|
||
animation: rt-fresh 1.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
|
||
.rt-heard.is-bumped {
|
||
border-radius: 2px;
|
||
animation: rt-fresh 1.2s ease-out;
|
||
}
|
||
|
||
@keyframes rt-fresh {
|
||
from { background-color: var(--rt-fresh); }
|
||
to { background-color: transparent; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.rt-row.is-fresh,
|
||
.rt-heard.is-bumped {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
.rt-line {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
align-items: start;
|
||
}
|
||
|
||
.rt-main {
|
||
display: grid;
|
||
grid-template-columns: 5.25rem 1.5rem minmax(0, 1fr);
|
||
column-gap: 0.6rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 0.45rem 0.25rem 0.45rem 0.75rem;
|
||
text-align: left;
|
||
color: inherit;
|
||
background: none;
|
||
border: 0;
|
||
border-radius: 0;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.rt-line:hover {
|
||
background-color: var(--rt-row-hover);
|
||
}
|
||
|
||
.rt-main:focus-visible,
|
||
.rt-keep:focus-visible {
|
||
outline: 2px solid var(--text-primary);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
.rt-time {
|
||
grid-row: span 2;
|
||
padding-top: 0.1rem;
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rt-glyph {
|
||
grid-row: span 2;
|
||
padding-top: 0.05rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.rt-l1,
|
||
.rt-l2 {
|
||
display: block;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rt-l1 {
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.rt-l1 .rt-who {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.rt-l1 .rt-text {
|
||
margin-left: 0.4rem;
|
||
}
|
||
|
||
.rt-l2 {
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.rt-sep::before {
|
||
content: "·";
|
||
margin: 0 0.35rem;
|
||
}
|
||
|
||
.rt-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
margin-left: 0.4rem;
|
||
padding: 0 0.4rem;
|
||
font-size: 0.875rem;
|
||
color: var(--text-muted);
|
||
background-color: var(--bg-tertiary);
|
||
border-radius: 999px;
|
||
vertical-align: baseline;
|
||
}
|
||
|
||
.rt-dot {
|
||
display: inline-block;
|
||
width: 0.5rem;
|
||
height: 0.5rem;
|
||
border-radius: 50%;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.rt-route-dot--flood { background-color: var(--rt-route-flood); }
|
||
.rt-route-dot--direct { background-color: var(--rt-route-direct); }
|
||
|
||
.rt-route {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.rt-path,
|
||
.rt-mono {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.875em;
|
||
}
|
||
|
||
.rt-keep {
|
||
align-self: start;
|
||
margin: 0.3rem 0.4rem 0 0;
|
||
padding: 0.2rem 0.45rem;
|
||
color: var(--text-muted);
|
||
background: none;
|
||
border: 0;
|
||
border-radius: 0.25rem;
|
||
opacity: 0;
|
||
}
|
||
|
||
.rt-row:hover .rt-keep,
|
||
.rt-row:focus-within .rt-keep,
|
||
.rt-keep[aria-pressed="true"] {
|
||
opacity: 1;
|
||
}
|
||
|
||
.rt-keep[aria-pressed="true"] {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.rt-keep:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* A command's reply hangs under the message that triggered it. */
|
||
.rt-reply {
|
||
margin: -0.1rem 0.75rem 0.5rem calc(0.75rem + 5.25rem + 1.5rem + 1.2rem);
|
||
padding: 0.35rem 0.75rem 0.4rem;
|
||
background-color: var(--rt-bot-wash);
|
||
border-left: 1px solid var(--rt-bot-rule);
|
||
border-radius: 0 0.375rem 0.375rem 0;
|
||
}
|
||
|
||
.rt-reply-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.45rem;
|
||
color: var(--rt-bot-text);
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.rt-reply-head .rt-mono {
|
||
font-weight: 400;
|
||
}
|
||
|
||
.rt-reply-text {
|
||
margin: 0.1rem 0;
|
||
color: var(--text-color);
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.rt-row.is-open .rt-reply-text {
|
||
display: block;
|
||
}
|
||
|
||
.rt-reply-status {
|
||
font-size: 0.875rem;
|
||
color: var(--text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.rt-reply-status.is-failed {
|
||
color: var(--text-danger);
|
||
}
|
||
|
||
/* Expanded detail */
|
||
.rt-detail {
|
||
margin: 0 0.75rem 0.75rem calc(0.75rem + 5.25rem + 1.5rem + 1.2rem);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.rt-detail[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.rt-detail-text {
|
||
margin: 0 0 0.5rem;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.rt-facts {
|
||
display: grid;
|
||
grid-template-columns: max-content minmax(0, 1fr);
|
||
gap: 0.15rem 0.75rem;
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
|
||
.rt-facts dt {
|
||
color: var(--text-muted);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.rt-facts dd {
|
||
margin: 0;
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.rt-receptions {
|
||
width: 100%;
|
||
margin: 0 0 0.5rem;
|
||
font-variant-numeric: tabular-nums;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.rt-receptions th {
|
||
color: var(--text-muted);
|
||
font-weight: 400;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border-color);
|
||
padding: 0.15rem 0.75rem 0.15rem 0;
|
||
}
|
||
|
||
.rt-receptions td {
|
||
padding: 0.15rem 0.75rem 0.15rem 0;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.rt-receptions td.rt-path {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.rt-detail-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.rt-empty {
|
||
padding: 2rem 1rem;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
}
|
||
|
||
.rt-empty .btn {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
.rt-waiting {
|
||
color: var(--text-color);
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.rt-waiting-hint {
|
||
max-width: 36rem;
|
||
margin: 0.35rem auto 0;
|
||
}
|
||
|
||
::selection {
|
||
background-color: var(--rt-fresh);
|
||
}
|
||
|
||
.rt-pending {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Filter lenses carry the colors of what they show: messages in the
|
||
message blue, commands in the bot's violet. The chosen lens fills. */
|
||
.rt-lens {
|
||
--lens: var(--rt-lens-all);
|
||
color: color-mix(in oklch, var(--lens) 82%, var(--rt-type-mix));
|
||
background-color: transparent;
|
||
border: 1px solid color-mix(in srgb, var(--lens) 55%, transparent);
|
||
}
|
||
|
||
.rt-lens--message { --lens: var(--rt-lens-message); }
|
||
.rt-lens--command { --lens: var(--rt-bot); }
|
||
|
||
.rt-lens:hover {
|
||
color: color-mix(in oklch, var(--lens) 82%, var(--rt-type-mix));
|
||
background-color: color-mix(in srgb, var(--lens) 12%, transparent);
|
||
}
|
||
|
||
.btn-check:checked + .rt-lens {
|
||
color: var(--rt-lens-on);
|
||
background-color: var(--lens);
|
||
border-color: var(--lens);
|
||
}
|
||
|
||
.btn-check:focus-visible + .rt-lens {
|
||
outline: 2px solid var(--lens);
|
||
outline-offset: 1px;
|
||
box-shadow: none;
|
||
}
|
||
|
||
#rt-pause[aria-pressed="true"] {
|
||
color: #fff;
|
||
background-color: #0d6efd;
|
||
border-color: #0d6efd;
|
||
}
|
||
|
||
/* Phone: a compact sticky strip, filters behind a button, two-line rows. */
|
||
@media (max-width: 767.98px) {
|
||
.rt-strip {
|
||
gap: 0.25rem 0.9rem;
|
||
min-height: 48px;
|
||
padding: 0.4rem 0.75rem;
|
||
}
|
||
|
||
.rt-strip__wide,
|
||
.rt-strip .rt-pause-label,
|
||
.rt-strip__label--rate {
|
||
display: none !important;
|
||
}
|
||
|
||
.rt-strip__label--last {
|
||
display: none !important;
|
||
}
|
||
|
||
#rt-last::before {
|
||
content: "heard ";
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Readings keep their full width; when the held-rows button joins
|
||
Pause and there isn't room, the controls drop to their own line
|
||
at the right instead of squeezing the readings into each other. */
|
||
.rt-strip {
|
||
gap: 0.35rem 0.75rem;
|
||
}
|
||
|
||
.rt-strip__spacer {
|
||
display: none;
|
||
}
|
||
|
||
.rt-strip__controls {
|
||
margin-left: auto;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.rt-pending-more {
|
||
display: none;
|
||
}
|
||
|
||
.rt-filters-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.rt-filters > :not(.rt-filters-toggle):not(.rt-filter-count) {
|
||
display: none;
|
||
}
|
||
|
||
.rt-filters.is-open > :not(.rt-filters-toggle):not(.rt-filter-count) {
|
||
display: flex;
|
||
}
|
||
|
||
.rt-filters.is-open .form-select,
|
||
.rt-filters.is-open .form-control,
|
||
.rt-filters.is-open .rt-search {
|
||
flex: 1 1 100%;
|
||
max-width: none;
|
||
}
|
||
|
||
.rt-main {
|
||
grid-template-columns: 1.5rem minmax(0, 1fr);
|
||
column-gap: 0.5rem;
|
||
padding-left: 0.6rem;
|
||
}
|
||
|
||
.rt-time {
|
||
display: none;
|
||
}
|
||
|
||
.rt-l2 .rt-time-inline {
|
||
display: inline;
|
||
}
|
||
|
||
.rt-reply,
|
||
.rt-detail {
|
||
margin-left: calc(0.6rem + 1.5rem + 0.5rem);
|
||
margin-right: 0.6rem;
|
||
}
|
||
|
||
.rt-keep {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.rt-l2 .rt-time-inline {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (pointer: coarse) {
|
||
.rt-keep {
|
||
opacity: 1;
|
||
padding: 0.55rem 0.6rem;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
/* ── Packet byte breakdown ─────────────────────────────────────────────
|
||
Surfaces and text come from the base.html theme tokens, light first
|
||
with dark overrides like the rest of the viewer. Byte segments are a
|
||
data legend, so they use the dashboard's categorical palette as tints,
|
||
never the lamp colors; each segment card repeats its swatch and name. */
|
||
#packetDetailModal {
|
||
--seg-transport: #eb6834;
|
||
--seg-header: #eda100;
|
||
--seg-pathlen: #4a3aa7;
|
||
--seg-path: #1baf7a;
|
||
--seg-payload: #2a78d6;
|
||
--seg-channel: #e87ba4;
|
||
--seg-mac: #eb6834;
|
||
--seg-cipher: #4a3aa7;
|
||
--seg-ink-mix: black;
|
||
--seg-ink-share: 42%;
|
||
}
|
||
|
||
[data-theme="dark"] #packetDetailModal {
|
||
--seg-transport: #d95926;
|
||
--seg-header: #c98500;
|
||
--seg-pathlen: #9085e9;
|
||
--seg-path: #199e70;
|
||
--seg-payload: #3987e5;
|
||
--seg-channel: #d55181;
|
||
--seg-mac: #d95926;
|
||
--seg-cipher: #9085e9;
|
||
--seg-ink-mix: white;
|
||
--seg-ink-share: 30%;
|
||
}
|
||
|
||
#packetDetailModal .modal-content {
|
||
color: var(--text-color);
|
||
background-color: var(--bg-color);
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
#packetDetailModal .modal-header,
|
||
#packetDetailModal .modal-footer {
|
||
background-color: var(--card-header-bg);
|
||
border-color: var(--border-color);
|
||
}
|
||
|
||
#packetDetailModal .modal-title {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 0.25rem 0.6rem;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.pkt-total {
|
||
color: var(--text-muted);
|
||
font-weight: 400;
|
||
font-size: 0.875rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
[data-theme="dark"] #packetDetailModal .btn-close {
|
||
filter: invert(1) grayscale(1) brightness(1.6);
|
||
}
|
||
|
||
.packet-detail-body {
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
background-color: var(--bg-color);
|
||
}
|
||
|
||
.pkt-facts {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||
gap: 0.6rem 1.5rem;
|
||
margin: 0 0 1.25rem;
|
||
}
|
||
|
||
.pkt-facts dt,
|
||
.pkt-label {
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.pkt-facts dd {
|
||
margin: 0.1rem 0 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.pkt-facts .pkt-facts-wide {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.pkt-mono,
|
||
.pkt-facts #detail-path {
|
||
padding: 0;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.875rem;
|
||
color: var(--text-color);
|
||
background: none;
|
||
}
|
||
|
||
.pkt-section {
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.pkt-label {
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
|
||
/* Raw bytes: one well, wrapping only between bytes. */
|
||
.hex-breakdown {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.875rem;
|
||
line-height: 2;
|
||
padding: 0.75rem 1rem;
|
||
color: var(--text-color);
|
||
background-color: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 0.375rem;
|
||
word-break: normal;
|
||
overflow-wrap: normal;
|
||
}
|
||
|
||
.hex-header, .hex-pathlen, .hex-path, .hex-payload, .hex-transport,
|
||
.hex-channel-hash, .hex-cipher-mac, .hex-ciphertext {
|
||
padding: 0.1rem 0.2rem;
|
||
border-radius: 2px;
|
||
font-weight: 600;
|
||
color: color-mix(in oklch, var(--seg) var(--seg-ink-share), var(--seg-ink-mix));
|
||
background-color: color-mix(in srgb, var(--seg) 34%, transparent);
|
||
-webkit-box-decoration-break: clone;
|
||
box-decoration-break: clone;
|
||
}
|
||
|
||
.hex-transport, .seg-transport { --seg: var(--seg-transport); }
|
||
.hex-header, .seg-header { --seg: var(--seg-header); }
|
||
.hex-pathlen, .seg-pathlen { --seg: var(--seg-pathlen); }
|
||
.hex-path, .seg-path { --seg: var(--seg-path); }
|
||
.hex-payload, .seg-payload { --seg: var(--seg-payload); }
|
||
.hex-channel-hash, .seg-channel { --seg: var(--seg-channel); }
|
||
.hex-cipher-mac, .seg-mac { --seg: var(--seg-mac); }
|
||
.hex-ciphertext, .seg-cipher { --seg: var(--seg-cipher); }
|
||
|
||
.hex-unknown {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Segment cards: one level of cards, each labeled with its swatch. */
|
||
.pkt-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0.75rem;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.pkt-grid--3 {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
|
||
.segment-card {
|
||
min-width: 0;
|
||
padding: 0.85rem 1rem;
|
||
background-color: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 0.375rem;
|
||
}
|
||
|
||
.segment-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
margin: 0 0 0.15rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.seg-swatch {
|
||
flex: 0 0 auto;
|
||
width: 0.7rem;
|
||
height: 0.7rem;
|
||
border-radius: 2px;
|
||
background-color: var(--seg);
|
||
}
|
||
|
||
.segment-bytes {
|
||
margin-bottom: 0.5rem;
|
||
color: var(--text-muted);
|
||
font-size: 0.7rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.segment-value {
|
||
display: block;
|
||
padding: 0.45rem 0.6rem;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.875rem;
|
||
color: var(--text-color);
|
||
background-color: var(--bg-tertiary);
|
||
border-radius: 0.25rem;
|
||
word-break: normal;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.segment-description {
|
||
margin: 0.5rem 0 0;
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.header-bits-table {
|
||
--bs-table-bg: transparent;
|
||
--bs-table-accent-bg: transparent;
|
||
margin: 0.6rem 0 0;
|
||
font-size: 0.875rem;
|
||
color: var(--text-color);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.header-bits-table > :not(:first-child) {
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
|
||
.header-bits-table > :not(caption) > * > * {
|
||
color: var(--text-color);
|
||
border-color: var(--border-color);
|
||
background-color: transparent;
|
||
}
|
||
|
||
.header-bits-table thead th {
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.header-bits-table .bits-binary {
|
||
font-family: var(--font-mono);
|
||
}
|
||
|
||
/* Decrypted message */
|
||
.decrypted-card .segment-title {
|
||
color: var(--text-success);
|
||
}
|
||
|
||
.decrypted-fields {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||
gap: 0.5rem 1.5rem;
|
||
margin: 0.5rem 0 0.75rem;
|
||
}
|
||
|
||
.decrypted-label {
|
||
display: block;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.decrypted-value {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.message-content {
|
||
padding: 0.6rem 0.75rem;
|
||
margin-top: 0.25rem;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
background-color: var(--bg-tertiary);
|
||
border-radius: 0.25rem;
|
||
}
|
||
|
||
#grouptext-breakdown,
|
||
#advert-breakdown {
|
||
display: none;
|
||
}
|
||
|
||
#grouptext-breakdown.visible,
|
||
#advert-breakdown.show-section {
|
||
display: block;
|
||
}
|
||
|
||
#advert-node-info > div + div {
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
#advert-node-info strong {
|
||
font-weight: 400;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
@media (max-width: 767.98px) {
|
||
.packet-detail-body {
|
||
max-height: none;
|
||
}
|
||
|
||
.pkt-grid,
|
||
.pkt-grid--3 {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="rt-head">
|
||
<h1 class="h3"><i class="fas fa-broadcast-tower" aria-hidden="true"></i> Real-time</h1>
|
||
<a href="/logs" class="btn btn-sm btn-outline-secondary">
|
||
<i class="fas fa-file-alt" aria-hidden="true"></i> Logs
|
||
</a>
|
||
</div>
|
||
|
||
<!-- Receiver strip: is the radio hearing things right now? -->
|
||
<section class="rt-strip" aria-label="Receiver status">
|
||
<span class="rt-strip__item">
|
||
<span class="status-indicator status-warning" id="rt-lamp" aria-hidden="true"></span>
|
||
<strong id="rt-state" role="status">Connecting…</strong>
|
||
</span>
|
||
<span class="rt-strip__item rt-rx" title="Lights each time the radio hears a packet">
|
||
<span class="rt-rx__led" id="rt-rx" aria-hidden="true"></span>
|
||
<span class="rt-strip__label">RX</span>
|
||
</span>
|
||
<span class="rt-strip__item">
|
||
<span class="rt-strip__label rt-strip__label--last">Last heard</span>
|
||
<span id="rt-last">—</span>
|
||
</span>
|
||
<span class="rt-strip__item">
|
||
<span class="rt-strip__label rt-strip__label--rate">Rate</span>
|
||
<span id="rt-rate">—</span>
|
||
</span>
|
||
<span class="rt-strip__item rt-strip__wide" title="Packets heard per 30 seconds over the last 10 minutes">
|
||
<span class="rt-strip__label">10 min</span>
|
||
<svg id="rt-spark" class="rt-spark" viewBox="0 0 120 24" preserveAspectRatio="none" role="img" aria-label="Packets heard per 30 seconds, last 10 minutes"></svg>
|
||
</span>
|
||
<span class="rt-strip__spacer"></span>
|
||
<span class="rt-strip__item rt-strip__controls">
|
||
<button type="button" class="btn btn-sm btn-outline-primary rt-pending" id="rt-pending" hidden></button>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" id="rt-pause" aria-pressed="false" aria-label="Pause the tape" title="Pause the tape (P)">
|
||
<i class="fas fa-pause" aria-hidden="true"></i> <span class="rt-pause-label">Pause</span>
|
||
</button>
|
||
</span>
|
||
</section>
|
||
|
||
<!-- Filters: lenses on the same rows, never a split into more rows -->
|
||
<div class="rt-filters" id="rt-filters" role="search" aria-label="Filter the tape">
|
||
<button type="button" class="btn btn-sm btn-outline-secondary rt-filters-toggle" id="rt-filters-toggle" aria-expanded="false" aria-controls="rt-filters">
|
||
<i class="fas fa-filter" aria-hidden="true"></i> Filters <span id="rt-filters-active"></span>
|
||
</button>
|
||
<div class="btn-group btn-group-sm" role="group" aria-label="Show">
|
||
<input type="radio" class="btn-check" name="rt-kind" id="rt-kind-all" value="all" checked>
|
||
<label class="btn rt-lens rt-lens--all" for="rt-kind-all"><i class="fas fa-layer-group" aria-hidden="true"></i> Everything</label>
|
||
<input type="radio" class="btn-check" name="rt-kind" id="rt-kind-message" value="message">
|
||
<label class="btn rt-lens rt-lens--message" for="rt-kind-message"><i class="fas fa-comment" aria-hidden="true"></i> Messages</label>
|
||
<input type="radio" class="btn-check" name="rt-kind" id="rt-kind-command" value="command">
|
||
<label class="btn rt-lens rt-lens--command" for="rt-kind-command"><i class="fas fa-robot" aria-hidden="true"></i> Commands</label>
|
||
</div>
|
||
<select class="form-select form-select-sm" id="rt-type" aria-label="Packet type">
|
||
<option value="">All packet types</option>
|
||
</select>
|
||
<select class="form-select form-select-sm" id="rt-channel" aria-label="Channel">
|
||
<option value="">All channels</option>
|
||
</select>
|
||
<input type="search" class="form-control form-control-sm rt-search" id="rt-search" placeholder="Search sender, text, prefix or hash" aria-label="Search the tape" title="Search (/)">
|
||
<button type="button" class="btn btn-sm btn-link" id="rt-clear-filters" hidden>Clear filters</button>
|
||
<span class="rt-filter-count" id="rt-filter-count" aria-live="polite"></span>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="rt-clear" title="Remove every row except the ones you kept">Clear tape</button>
|
||
</div>
|
||
|
||
<section class="rt-kept" id="rt-kept" hidden aria-labelledby="rt-kept-title">
|
||
<h2 class="rt-kept-title" id="rt-kept-title"><i class="fas fa-star" aria-hidden="true"></i> Kept <span id="rt-kept-count"></span></h2>
|
||
<ol class="rt-kept-list" id="rt-kept-list"></ol>
|
||
</section>
|
||
|
||
<ol class="rt-tape" id="rt-tape" aria-label="Transmissions heard, newest first">
|
||
<li class="rt-empty" id="rt-empty">Connecting to the bot…</li>
|
||
</ol>
|
||
|
||
<!-- 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 modal-fullscreen-md-down">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="packetDetailModalLabel">
|
||
<span><i class="fas fa-microchip" aria-hidden="true"></i> Packet byte breakdown</span>
|
||
<span id="packet-total-bytes" class="pkt-total"></span>
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body packet-detail-body">
|
||
<dl class="pkt-facts">
|
||
<div>
|
||
<dt>Packet hash</dt>
|
||
<dd><code id="detail-packet-hash" class="pkt-mono"></code></dd>
|
||
</div>
|
||
<div>
|
||
<dt>Type</dt>
|
||
<dd id="detail-payload-type"></dd>
|
||
</div>
|
||
<div>
|
||
<dt>Route</dt>
|
||
<dd id="detail-route-type"></dd>
|
||
</div>
|
||
<div>
|
||
<dt>Heard at</dt>
|
||
<dd id="detail-timestamp"></dd>
|
||
</div>
|
||
<div class="pkt-facts-wide">
|
||
<dt>Path</dt>
|
||
<dd id="detail-path"></dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<!-- Decoder note: shown above the bytes so it frames what follows -->
|
||
<div id="decoder-error" class="alert alert-warning" role="status" style="display: none;">
|
||
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
|
||
<span id="decoder-error-message"></span>
|
||
</div>
|
||
|
||
<section class="pkt-section" aria-labelledby="pkt-raw-title">
|
||
<h6 class="pkt-label" id="pkt-raw-title">Raw packet</h6>
|
||
<div id="hex-breakdown" class="hex-breakdown"></div>
|
||
</section>
|
||
|
||
<div class="pkt-grid">
|
||
<div class="segment-card">
|
||
<h6 class="segment-title"><span class="seg-swatch seg-header" aria-hidden="true"></span>Header</h6>
|
||
<div class="segment-bytes" id="header-bytes">Byte 0</div>
|
||
<code id="header-value" class="segment-value"></code>
|
||
<table class="table table-sm header-bits-table">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Bits</th>
|
||
<th scope="col">Field</th>
|
||
<th scope="col">Value</th>
|
||
<th scope="col">Binary</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="header-bits-body">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="segment-card">
|
||
<h6 class="segment-title"><span class="seg-swatch seg-pathlen" aria-hidden="true"></span>Path length</h6>
|
||
<div class="segment-bytes" id="pathlen-bytes">Byte 1</div>
|
||
<code id="pathlen-value" class="segment-value"></code>
|
||
<p id="pathlen-description" class="segment-description"></p>
|
||
</div>
|
||
<div class="segment-card">
|
||
<h6 class="segment-title"><span class="seg-swatch seg-path" aria-hidden="true"></span>Path</h6>
|
||
<div class="segment-bytes" id="path-bytes"></div>
|
||
<code id="path-value" class="segment-value"></code>
|
||
<p id="path-description" class="segment-description"></p>
|
||
</div>
|
||
<div class="segment-card">
|
||
<h6 class="segment-title"><span class="seg-swatch seg-payload" aria-hidden="true"></span>Payload</h6>
|
||
<div class="segment-bytes" id="payload-bytes"></div>
|
||
<code id="payload-value" class="segment-value"></code>
|
||
<p id="payload-description" class="segment-description"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GroupText Payload Breakdown (shown only for GroupText packets) -->
|
||
<section id="grouptext-breakdown" class="pkt-section" aria-labelledby="pkt-grouptext-title">
|
||
<h6 class="pkt-label" id="pkt-grouptext-title">Group text payload</h6>
|
||
<div id="grouptext-hex" class="hex-breakdown mb-3"></div>
|
||
<div class="pkt-grid pkt-grid--3">
|
||
<div class="segment-card">
|
||
<h6 class="segment-title"><span class="seg-swatch seg-channel" aria-hidden="true"></span>Channel hash</h6>
|
||
<div class="segment-bytes">Payload byte 0</div>
|
||
<code id="channel-hash-value" class="segment-value"></code>
|
||
<p class="segment-description">First byte of the SHA-256 of the channel's shared key</p>
|
||
</div>
|
||
<div class="segment-card">
|
||
<h6 class="segment-title"><span class="seg-swatch seg-mac" aria-hidden="true"></span>Cipher MAC</h6>
|
||
<div class="segment-bytes">Payload bytes 1–2</div>
|
||
<code id="cipher-mac-value" class="segment-value"></code>
|
||
<p class="segment-description">Message authentication code for the encrypted data</p>
|
||
</div>
|
||
<div class="segment-card">
|
||
<h6 class="segment-title"><span class="seg-swatch seg-cipher" aria-hidden="true"></span>Ciphertext</h6>
|
||
<div class="segment-bytes" id="ciphertext-bytes"></div>
|
||
<code id="ciphertext-value" class="segment-value"></code>
|
||
<p class="segment-description">Encrypted timestamp, flags and message</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Decrypted Message (if available) -->
|
||
<div id="decrypted-section" style="display: none;">
|
||
<div class="segment-card decrypted-card">
|
||
<h6 class="segment-title"><i class="fas fa-unlock" aria-hidden="true"></i> Decrypted message</h6>
|
||
<div class="decrypted-fields">
|
||
<div>
|
||
<span class="decrypted-label">Sender</span>
|
||
<span id="decrypted-sender" class="decrypted-value"></span>
|
||
</div>
|
||
<div>
|
||
<span class="decrypted-label">Sent at</span>
|
||
<span id="decrypted-timestamp" class="decrypted-value"></span>
|
||
</div>
|
||
<div>
|
||
<span class="decrypted-label">Channel</span>
|
||
<span id="decrypted-channel" class="decrypted-value"></span>
|
||
</div>
|
||
</div>
|
||
<span class="decrypted-label">Message</span>
|
||
<div id="decrypted-message" class="message-content"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Advert Payload Breakdown (shown only for Advert packets) -->
|
||
<section id="advert-breakdown" class="pkt-section" aria-labelledby="pkt-advert-title">
|
||
<h6 class="pkt-label" id="pkt-advert-title">Advert payload</h6>
|
||
<div class="pkt-grid">
|
||
<div class="segment-card">
|
||
<h6 class="segment-title">Public key</h6>
|
||
<code id="advert-pubkey" class="segment-value"></code>
|
||
</div>
|
||
<div class="segment-card">
|
||
<h6 class="segment-title">Node</h6>
|
||
<div id="advert-node-info"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||
<button type="button" class="btn btn-primary" id="copy-raw-packet">
|
||
<i class="fas fa-copy" aria-hidden="true"></i> Copy raw hex
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block page_libs %}
|
||
<!-- SHA-256 for channel key derivation (works in non-HTTPS contexts); vendored, see static/vendor/README.md -->
|
||
<script src="{{ url_for('static', filename='vendor/js-sha256/sha256.min.js') }}"></script>
|
||
{% 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).
|
||
const decoderReady = import('/static/js/meshcore-browser.mjs').then(function(m) {
|
||
window.meshCoreDecoder = m;
|
||
window._MeshCoreKeyStore = m.MeshCoreKeyStore;
|
||
return m;
|
||
}).catch(function(e) {
|
||
console.warn('MeshCore decoder unavailable:', e);
|
||
return null;
|
||
});
|
||
|
||
// Reuse the shared socket from base.html's ModernConnectionManager.
|
||
const socket = window.connectionManager && window.connectionManager.socket;
|
||
|
||
// Raw packet payloads by reception id, for the byte breakdown modal.
|
||
const packetStore = new Map();
|
||
window.packetStore = packetStore;
|
||
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;
|
||
|
||
// 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';
|
||
}
|
||
|
||
for (const entry of channelEntries) {
|
||
try {
|
||
const testKeyStore = new (window._MeshCoreKeyStore || class { addChannelSecrets(){} })({
|
||
channelSecrets: [entry.secret]
|
||
});
|
||
const testResult = window.meshCoreDecoder.decodePacket(fullPacketHex, { keyStore: testKeyStore });
|
||
if (testResult?.payload?.decoded?.decrypted) {
|
||
return entry.name;
|
||
}
|
||
} catch (e) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
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 {
|
||
const secretBytes = new Uint8Array(secretHex.match(/.{1,2}/g).map(b => parseInt(b, 16)));
|
||
if (typeof sha256 !== 'undefined') {
|
||
const hashHex = sha256(secretBytes);
|
||
return hashHex.slice(0, 2).toUpperCase();
|
||
}
|
||
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;
|
||
}
|
||
|
||
// Build the decoder key store from the configured channels. It waits for
|
||
// the decoder module, which used to lose the race and leave the page with
|
||
// no key store at all.
|
||
let keyStoreStarted = false;
|
||
async function initializeKeyStore() {
|
||
if (keyStoreStarted) return;
|
||
keyStoreStarted = true;
|
||
try {
|
||
const response = await fetch('/api/channels');
|
||
const data = await response.json();
|
||
|
||
let radioChannels = 0;
|
||
let decodeOnlyChannels = 0;
|
||
|
||
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) {
|
||
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);
|
||
const displayName = channel.name.startsWith('#') ? channel.name : `#${channel.name}`;
|
||
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++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await decoderReady;
|
||
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);
|
||
}
|
||
decodePendingRows();
|
||
}
|
||
|
||
// Derive hashtag channel key: first 16 bytes of SHA256(lowercase_channel_name)
|
||
async function deriveHashtagKey(channelName) {
|
||
try {
|
||
let name = channelName.toLowerCase();
|
||
if (!name.startsWith('#')) {
|
||
name = '#' + name;
|
||
}
|
||
if (typeof sha256 !== 'undefined') {
|
||
const hashHex = sha256(name);
|
||
return hashHex.slice(0, 32);
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ── Receiver tape ─────────────────────────────────────────────────────
|
||
// One row per transmission. A packet, the message decrypted from it and a
|
||
// command it triggered are one row, joined by packet hash; a command's
|
||
// reply hangs under that row, and the reply's own echoes fold into it.
|
||
|
||
const MAX_ROWS = 300; // tape rows kept on the page (kept rows don't count)
|
||
const MAX_PENDING = 500; // events held while the tape is paused or being read
|
||
const POINTER_IDLE_MS = 5000; // a resting pointer stops holding the tape after this
|
||
const RATE_WINDOW_S = 300;
|
||
const SPARK_BUCKETS = 20;
|
||
const SPARK_BUCKET_S = 30;
|
||
|
||
const txs = new Map(); // row key -> transmission
|
||
const replyOwner = new Map(); // reply packet hash -> key of the row whose command sent it
|
||
const commandIndex = new Map(); // command_id -> row key
|
||
const heardTimes = []; // server epoch seconds of every reception, newest last
|
||
const seenTypes = new Set();
|
||
const seenChannels = new Set();
|
||
const pending = [];
|
||
const view = { kind: 'all', type: '', channel: '', q: '' };
|
||
const hold = { paused: false, scrolled: false, pointer: false };
|
||
const link = { socket: socket ? 'connecting' : 'none', offline: false, offlineSince: null, zombie: false, verdict: null };
|
||
|
||
let synthetic = 0;
|
||
let connectCount = 0;
|
||
let clockOffset = null; // browser clock minus server clock, from live deliveries
|
||
let coverageStart = null; // earliest server time the rate and sparkline can speak for
|
||
let lastHeard = null;
|
||
let lastPointerMove = 0;
|
||
let pendingDropped = 0;
|
||
let clearedAt = null;
|
||
let divider = null;
|
||
|
||
const tape = document.getElementById('rt-tape');
|
||
const emptyRow = document.getElementById('rt-empty');
|
||
const keptSection = document.getElementById('rt-kept');
|
||
const keptList = document.getElementById('rt-kept-list');
|
||
const strip = document.querySelector('.rt-strip');
|
||
|
||
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;
|
||
}
|
||
|
||
// The Clipboard API exists only on HTTPS and localhost; a LAN viewer over
|
||
// plain HTTP falls back to a hidden textarea and execCommand.
|
||
function copyText(text) {
|
||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||
return new Promise((resolve, reject) => {
|
||
const area = makeElement('textarea');
|
||
area.value = text;
|
||
area.setAttribute('readonly', '');
|
||
area.style.position = 'fixed';
|
||
area.style.opacity = '0';
|
||
document.body.appendChild(area);
|
||
area.select();
|
||
const ok = document.execCommand('copy');
|
||
area.remove();
|
||
ok ? resolve() : reject(new Error('copy refused'));
|
||
});
|
||
}
|
||
|
||
function makeIcon(className) {
|
||
const icon = makeElement('i', className);
|
||
icon.setAttribute('aria-hidden', 'true');
|
||
return icon;
|
||
}
|
||
|
||
function setButtonIconAndLabel(button, iconClass, label) {
|
||
const icon = makeIcon(iconClass);
|
||
button.replaceChildren(icon, document.createTextNode(` ${String(label)}`));
|
||
}
|
||
|
||
// ── Time ──
|
||
|
||
function nowServer() {
|
||
return Date.now() / 1000 - (clockOffset || 0);
|
||
}
|
||
|
||
function eventTime(data) {
|
||
// Commands and messages carry their own capture time; a command's
|
||
// stream_ts moves forward each time its repeat count is updated.
|
||
if (typeof data.timestamp === 'number') return data.timestamp;
|
||
if (typeof data.stream_ts === 'number') return data.stream_ts;
|
||
const parsed = data.datetime ? Date.parse(data.datetime) : NaN;
|
||
return Number.isFinite(parsed) ? parsed / 1000 : nowServer();
|
||
}
|
||
|
||
function noteDelivery(data) {
|
||
// Live rows reach the browser within a poll of being written, so the
|
||
// smallest gap seen is the clock difference plus a little delivery time.
|
||
if (data.replayed || typeof data.stream_ts !== 'number') return;
|
||
const gap = Date.now() / 1000 - data.stream_ts;
|
||
if (clockOffset === null || gap < clockOffset) clockOffset = gap;
|
||
}
|
||
|
||
function clockText(t) {
|
||
return new Date(t * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
function agoText(t) {
|
||
const s = Math.max(0, Math.round(nowServer() - t));
|
||
if (s < 5) return 'just now';
|
||
if (s < 60) return `${s} s ago`;
|
||
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
||
return `at ${clockText(t)}`;
|
||
}
|
||
|
||
// ── Labels ──
|
||
|
||
const TYPE_LABELS = {
|
||
REQ: 'Request', RESPONSE: 'Response', TXT_MSG: 'Direct message', ACK: 'Ack',
|
||
ADVERT: 'Advert', GRP_TXT: 'Group text', GRP_DATA: 'Group data',
|
||
ANON_REQ: 'Anonymous request', PATH: 'Returned path', TRACE: 'Trace',
|
||
MULTIPART: 'Multipart', RAW_CUSTOM: 'Custom'
|
||
};
|
||
|
||
function typeLabel(name) {
|
||
return TYPE_LABELS[name] || String(name || 'Unknown');
|
||
}
|
||
|
||
function routeInfo(name) {
|
||
const n = String(name || '').toUpperCase();
|
||
if (n.includes('DIRECT')) return { label: n.startsWith('TRANSPORT') ? 'Direct (transport)' : 'Direct', kind: 'direct' };
|
||
if (n.includes('FLOOD')) return { label: n.startsWith('TRANSPORT') ? 'Flood (transport)' : 'Flood', kind: 'flood' };
|
||
return null;
|
||
}
|
||
|
||
function hopsText(h) {
|
||
if (h === null || h === undefined || h === '' || Number(h) === 255) return '? hops';
|
||
const n = Number(h);
|
||
if (!Number.isFinite(n)) return '? hops';
|
||
return `${n} hop${n === 1 ? '' : 's'}`;
|
||
}
|
||
|
||
function snrText(snr) {
|
||
const n = Number(snr);
|
||
return snr !== null && snr !== undefined && snr !== '' && Number.isFinite(n) ? `SNR ${n.toFixed(1)} dB` : null;
|
||
}
|
||
|
||
function pathList(path) {
|
||
if (!path) return [];
|
||
if (Array.isArray(path)) return path.map(p => String(p));
|
||
return String(path).split(/[,\s]+/).filter(Boolean);
|
||
}
|
||
|
||
function normHash(value) {
|
||
if (!value) return null;
|
||
const s = String(value).trim().toUpperCase();
|
||
return s && !/^0+$/.test(s) ? s : null;
|
||
}
|
||
|
||
/** Remove redundant leading [#chan] / [##chan] already shown in the channel chip. */
|
||
function stripLeadingChannelBracketTag(content, isDm) {
|
||
if (isDm || content == null || content === '') return content;
|
||
return String(content).replace(/^\s*\[[#]+[^\]]*\]\s*/u, '');
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/** A stable data hue per channel, so a channel is recognizable at a glance; its name is always beside it. */
|
||
// Each channel starts at its hashed slot, so its color is the same from one
|
||
// visit to the next; if another channel on this page already holds that
|
||
// slot it moves on to a free one, so up to 20 channels never share a color.
|
||
const CHANNEL_COLORS = 20; // --ch-0 … --ch-19 in the page CSS
|
||
const channelSlots = new Map();
|
||
function channelDotColor(channel) {
|
||
const s = String(channel || '').trim().toLowerCase().replace(/^#/, '');
|
||
if (!channelSlots.has(s)) {
|
||
const taken = new Set(channelSlots.values());
|
||
let slot = hashStringToUint(s) % CHANNEL_COLORS;
|
||
// Step 7 (coprime with 20): the next try is a clearly different hue,
|
||
// not the neighbor on the wheel, and every slot is still reached.
|
||
for (let i = 0; i < CHANNEL_COLORS && taken.has(slot); i++) slot = (slot + 7) % CHANNEL_COLORS;
|
||
channelSlots.set(s, slot);
|
||
}
|
||
return `var(--ch-${channelSlots.get(s)})`;
|
||
}
|
||
|
||
// ── Transmissions ──
|
||
|
||
function createTx(key, hash, t, history) {
|
||
const tx = {
|
||
key, hash, t, history,
|
||
packet: null, receptions: [],
|
||
message: null, decoded: null, advert: null, channelHash: null, decodeTried: false,
|
||
command: null, echoes: [],
|
||
kept: false, open: false, el: null,
|
||
lookup: null
|
||
};
|
||
txs.set(key, tx);
|
||
return tx;
|
||
}
|
||
|
||
function isHistory(data) {
|
||
// The replay that answers the first subscribe is what was heard before
|
||
// the page opened; a replay after a reconnect fills the gap and is live.
|
||
return !!data.replayed && connectCount <= 1;
|
||
}
|
||
|
||
function addReception(list, data, t) {
|
||
const path = pathList(data.path);
|
||
const key = `${t.toFixed(3)}|${data.snr}|${path.join(',')}`;
|
||
if (list.some(r => r.key === key)) return false;
|
||
const id = ++packetCounter;
|
||
packetStore.set(id, data);
|
||
list.push({ key, id, t, snr: data.snr, rssi: data.rssi, hops: data.hops ?? data.path_len, path, data });
|
||
list.sort((a, b) => a.t - b.t);
|
||
return true;
|
||
}
|
||
|
||
function noteHeard(t, replayed) {
|
||
if (replayed && gapWatch && (gapWatch.earliest === null || t < gapWatch.earliest)) gapWatch.earliest = t;
|
||
heardTimes.push(t);
|
||
if (heardTimes.length > 1 && heardTimes[heardTimes.length - 2] > t) heardTimes.sort((a, b) => a - b);
|
||
if (lastHeard === null || t > lastHeard) lastHeard = t;
|
||
if (replayed && (coverageStart === null || t < coverageStart)) coverageStart = t;
|
||
}
|
||
|
||
function noteType(name) {
|
||
if (!name || seenTypes.has(name)) return;
|
||
seenTypes.add(name);
|
||
addSortedOption(document.getElementById('rt-type'), name, typeLabel(name));
|
||
}
|
||
|
||
function noteChannel(name) {
|
||
if (!name || seenChannels.has(name)) return;
|
||
seenChannels.add(name);
|
||
addSortedOption(document.getElementById('rt-channel'), name, name);
|
||
}
|
||
|
||
function addSortedOption(select, value, label) {
|
||
const option = makeElement('option', '', label);
|
||
option.value = value;
|
||
const after = Array.from(select.options).slice(1).find(o => o.textContent.localeCompare(label) > 0);
|
||
select.insertBefore(option, after || null);
|
||
}
|
||
|
||
function applyPacket(data) {
|
||
const hash = normHash(data.packet_hash);
|
||
const t = eventTime(data);
|
||
noteDelivery(data);
|
||
|
||
// An echo of the bot's own reply belongs to the command that sent it.
|
||
if (hash && replyOwner.has(hash)) {
|
||
const host = txs.get(replyOwner.get(hash));
|
||
if (host && addReception(host.echoes, data, t)) {
|
||
noteHeard(t, data.replayed);
|
||
renderTx(host);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let tx = hash ? txs.get('h:' + hash) : null;
|
||
const isNew = !tx;
|
||
if (!tx) tx = createTx(hash ? 'h:' + hash : 'p:' + (++synthetic), hash, t, isHistory(data));
|
||
if (!addReception(tx.receptions, data, t)) return;
|
||
noteHeard(t, data.replayed);
|
||
if (!tx.packet) tx.packet = data;
|
||
if (t < tx.t) tx.t = t;
|
||
noteType(data.payload_type_name);
|
||
decodeRow(tx);
|
||
renderTx(tx, isNew ? 'new' : 'update');
|
||
}
|
||
|
||
// The replay on load is the newest 50 packets, which reach back far less
|
||
// than the newest 50 messages do, so an older message or command row can
|
||
// have a hash and no packet. Opening it fetches the packet's receptions
|
||
// from the bot's log. They fill the row only: they aren't new hearings, so
|
||
// they stay out of the spark and the replay's coverage.
|
||
async function lookUpPacket(tx) {
|
||
if (tx.packet || !tx.hash || tx.lookup === 'loading' || tx.lookup === 'none') return;
|
||
tx.lookup = 'loading';
|
||
let found = [];
|
||
try {
|
||
const url = `/api/packets/by-hash/${encodeURIComponent(tx.hash)}?near=${encodeURIComponent(tx.t)}`;
|
||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const body = await response.json();
|
||
found = Array.isArray(body.packets) ? body.packets : [];
|
||
} catch (e) {
|
||
console.warn('Realtime: could not look up packet', tx.hash, e);
|
||
tx.lookup = 'failed';
|
||
if (txs.get(tx.key) === tx) renderTx(tx);
|
||
return;
|
||
}
|
||
// The row may have been cleared or trimmed while the request was out.
|
||
if (txs.get(tx.key) !== tx) return;
|
||
found.forEach(data => {
|
||
if (normHash(data.packet_hash) !== tx.hash) return;
|
||
if (addReception(tx.receptions, data, eventTime(data)) && !tx.packet) tx.packet = data;
|
||
noteType(data.payload_type_name);
|
||
});
|
||
tx.lookup = tx.packet ? 'done' : 'none';
|
||
if (tx.packet) decodeRow(tx);
|
||
renderTx(tx);
|
||
}
|
||
|
||
// Commands stored before trigger hashes existed (or without a correlated
|
||
// packet) still pair with their message when sender, channel and text match
|
||
// exactly within a few seconds; anything looser stays on its own row.
|
||
const EXACT_MATCH_WINDOW_S = 20;
|
||
|
||
function sameWords(a, b) {
|
||
return String(a || '').trim() === String(b || '').trim();
|
||
}
|
||
|
||
function findMessageFor(cmd, t) {
|
||
for (const tx of txs.values()) {
|
||
const m = tx.message;
|
||
if (!m || tx.command || Math.abs(tx.t - t) > EXACT_MATCH_WINDOW_S) continue;
|
||
if (sameWords(m.sender, cmd.user) && sameWords(m.channel, cmd.channel) && sameWords(m.content, cmd.user_input)) return tx;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function findCommandFor(msg, t) {
|
||
for (const tx of txs.values()) {
|
||
const c = tx.command;
|
||
if (!c || tx.message || normHash(c.trigger_packet_hash) || Math.abs(tx.t - t) > EXACT_MATCH_WINDOW_S) continue;
|
||
if (sameWords(msg.sender, c.user) && sameWords(msg.channel, c.channel) && sameWords(msg.content, c.user_input)) return tx;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function rekey(tx, key, hash) {
|
||
txs.delete(tx.key);
|
||
commandIndex.forEach((k, id) => { if (k === tx.key) commandIndex.set(id, key); });
|
||
replyOwner.forEach((k, h) => { if (k === tx.key) replyOwner.set(h, key); });
|
||
tx.key = key;
|
||
tx.hash = hash;
|
||
txs.set(key, tx);
|
||
if (tx.el) tx.el.dataset.key = key;
|
||
}
|
||
|
||
// Move a command (and its reply's echoes) from its own row onto the row of
|
||
// the packet that carried its trigger.
|
||
function absorbCommand(tx, orphan) {
|
||
tx.command = orphan.command;
|
||
tx.echoes = tx.echoes.concat(orphan.echoes).sort((a, b) => a.t - b.t);
|
||
commandIndex.forEach((k, id) => { if (k === orphan.key) commandIndex.set(id, tx.key); });
|
||
replyOwner.forEach((k, h) => { if (k === orphan.key) replyOwner.set(h, tx.key); });
|
||
orphan.echoes = [];
|
||
removeTx(orphan);
|
||
}
|
||
|
||
function applyMessage(data) {
|
||
const hash = normHash(data.packet_hash);
|
||
const t = eventTime(data);
|
||
noteDelivery(data);
|
||
let tx;
|
||
if (hash) {
|
||
tx = txs.get('h:' + hash);
|
||
const orphan = findCommandFor(data, t);
|
||
if (!tx && orphan) {
|
||
rekey(orphan, 'h:' + hash, hash);
|
||
tx = orphan;
|
||
} else {
|
||
if (!tx) tx = createTx('h:' + hash, hash, t, isHistory(data));
|
||
if (orphan) absorbCommand(tx, orphan);
|
||
}
|
||
} else if ((tx = findCommandFor(data, t))) {
|
||
// joined to the command it triggered
|
||
} else {
|
||
const key = 'm:' + [t.toFixed(1), data.sender, data.content].join('|');
|
||
if (txs.has(key)) return;
|
||
tx = createTx(key, null, t, isHistory(data));
|
||
}
|
||
if (tx.message) return;
|
||
const isNew = !tx.el;
|
||
tx.message = data;
|
||
noteChannel(data.is_dm ? 'DM' : data.channel);
|
||
renderTx(tx, isNew ? 'new' : 'update');
|
||
}
|
||
|
||
function applyCommand(data) {
|
||
const id = data.command_id ? String(data.command_id) : null;
|
||
const t = eventTime(data);
|
||
noteDelivery(data);
|
||
let tx = id && commandIndex.has(id) ? txs.get(commandIndex.get(id)) : null;
|
||
if (!tx) {
|
||
const trigger = normHash(data.trigger_packet_hash);
|
||
if (trigger) {
|
||
tx = txs.get('h:' + trigger) || createTx('h:' + trigger, trigger, t, isHistory(data));
|
||
} else {
|
||
const key = 'c:' + (id || [data.timestamp, data.user, data.user_input].join('|'));
|
||
tx = txs.get(key) || findMessageFor(data, t) || createTx(key, null, t, isHistory(data));
|
||
}
|
||
if (id) commandIndex.set(id, tx.key);
|
||
}
|
||
const isNew = !tx.el;
|
||
tx.command = Object.assign({}, tx.command || {}, data);
|
||
noteChannel(data.channel);
|
||
|
||
const reply = normHash(tx.command.reply_packet_hash);
|
||
if (reply && !replyOwner.has(reply)) {
|
||
replyOwner.set(reply, tx.key);
|
||
const echoRow = txs.get('h:' + reply);
|
||
if (echoRow && echoRow !== tx) {
|
||
echoRow.receptions.forEach(r => {
|
||
if (!tx.echoes.some(e => e.key === r.key)) tx.echoes.push(r);
|
||
});
|
||
tx.echoes.sort((a, b) => a.t - b.t);
|
||
echoRow.receptions = []; // now owned by the reply; don't free their bytes
|
||
removeTx(echoRow);
|
||
}
|
||
}
|
||
renderTx(tx, isNew ? 'new' : 'update');
|
||
}
|
||
|
||
// Adverts carry their name and group text its message only inside the
|
||
// payload, so the browser decodes them where it can (no key, no text).
|
||
function decodeRow(tx) {
|
||
if (tx.decodeTried || tx.message || !tx.packet || !window.meshCoreDecoder) return;
|
||
const type = tx.packet.payload_type_name;
|
||
const raw = tx.packet.raw_packet_hex;
|
||
if (!raw || (type !== 'ADVERT' && type !== 'GRP_TXT')) return;
|
||
if (type === 'GRP_TXT' && !window.decoderKeyStore) return;
|
||
tx.decodeTried = true;
|
||
try {
|
||
const options = window.decoderKeyStore ? { keyStore: window.decoderKeyStore } : {};
|
||
const result = window.meshCoreDecoder.decodePacket(raw, options);
|
||
const payload = result?.payload?.decoded;
|
||
if (type === 'ADVERT' && payload?.appData) {
|
||
tx.advert = {
|
||
name: payload.appData.name || null,
|
||
role: payload.appData.deviceRole !== undefined ? getDeviceRoleName(payload.appData.deviceRole) : null
|
||
};
|
||
}
|
||
if (type === 'GRP_TXT' && payload) {
|
||
const ch = payload.channelHash;
|
||
tx.channelHash = ch === undefined || ch === null ? null
|
||
: (typeof ch === 'string' ? ch : ch.toString(16).padStart(2, '0')).toUpperCase();
|
||
if (payload.decrypted) {
|
||
const entries = channelHashMap.get(tx.channelHash) || [];
|
||
const channel = payload.decrypted.channelName
|
||
|| (entries.length ? entries.map(e => e.name).join(' or ') : null);
|
||
tx.decoded = {
|
||
sender: payload.decrypted.sender || null,
|
||
content: payload.decrypted.message || payload.decrypted.text || '',
|
||
channel
|
||
};
|
||
noteChannel(channel);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Undecodable here; the row keeps the server's reading.
|
||
}
|
||
}
|
||
|
||
function decodePendingRows() {
|
||
txs.forEach(tx => {
|
||
if (tx.decodeTried || tx.message) return;
|
||
decodeRow(tx);
|
||
if (tx.decodeTried) renderTx(tx);
|
||
});
|
||
}
|
||
|
||
function removeTx(tx) {
|
||
if (tx.el) tx.el.remove();
|
||
txs.delete(tx.key);
|
||
tx.receptions.concat(tx.echoes).forEach(r => packetStore.delete(r.id));
|
||
replyOwner.forEach((key, hash) => { if (key === tx.key) replyOwner.delete(hash); });
|
||
commandIndex.forEach((key, id) => { if (key === tx.key) commandIndex.delete(id); });
|
||
if (divider && !divider.nextElementSibling) {
|
||
divider.remove();
|
||
divider = null;
|
||
}
|
||
}
|
||
|
||
function enforceCap() {
|
||
let rows = tape.querySelectorAll('.rt-row');
|
||
let excess = rows.length - MAX_ROWS;
|
||
for (let i = rows.length - 1; i >= 0 && excess > 0; i--) {
|
||
const tx = txs.get(rows[i].dataset.key);
|
||
if (tx) {
|
||
removeTx(tx);
|
||
excess--;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Rendering ──
|
||
|
||
function readingOf(tx) {
|
||
if (tx.command) return 'command';
|
||
if (tx.message || tx.decoded) return 'message';
|
||
return 'packet';
|
||
}
|
||
|
||
function channelOf(tx) {
|
||
if (tx.message) return tx.message.is_dm ? 'DM' : (tx.message.channel || '');
|
||
if (tx.command) return tx.command.channel || '';
|
||
if (tx.decoded) return tx.decoded.channel || '';
|
||
return '';
|
||
}
|
||
|
||
function bestReception(tx) {
|
||
if (!tx.receptions.length) return null;
|
||
return tx.receptions.reduce((best, r) => (Number(r.snr) > Number(best.snr) ? r : best), tx.receptions[0]);
|
||
}
|
||
|
||
function appendChannelChip(parent, channel) {
|
||
if (!channel) return;
|
||
const chip = makeElement('span', 'rt-chip');
|
||
if (channel !== 'DM') {
|
||
const dot = makeElement('span', 'rt-dot');
|
||
dot.style.backgroundColor = channelDotColor(channel);
|
||
chip.appendChild(dot);
|
||
}
|
||
chip.appendChild(document.createTextNode(String(channel)));
|
||
parent.appendChild(chip);
|
||
}
|
||
|
||
function appendFact(parent, node) {
|
||
if (!node) return;
|
||
if (parent.childNodes.length) parent.appendChild(makeElement('span', 'rt-sep'));
|
||
parent.appendChild(typeof node === 'string' ? document.createTextNode(node) : node);
|
||
}
|
||
|
||
function buildFirstLine(tx) {
|
||
const line = makeElement('span', 'rt-l1');
|
||
const reading = readingOf(tx);
|
||
if (reading === 'command') {
|
||
const cmd = tx.command;
|
||
line.appendChild(makeElement('span', 'rt-who', cmd.user || 'Unknown'));
|
||
appendChannelChip(line, cmd.channel);
|
||
line.appendChild(makeElement('span', 'rt-text', stripLeadingChannelBracketTag(cmd.user_input || `${cmd.command || ''}`, false)));
|
||
} else if (reading === 'message') {
|
||
const msg = tx.message || tx.decoded;
|
||
const isDm = !!(tx.message && tx.message.is_dm);
|
||
line.appendChild(makeElement('span', 'rt-who', msg.sender || '?'));
|
||
appendChannelChip(line, channelOf(tx));
|
||
line.appendChild(makeElement('span', 'rt-text', stripLeadingChannelBracketTag(msg.content || '', isDm)));
|
||
} else {
|
||
const p = tx.packet || {};
|
||
const type = p.payload_type_name;
|
||
if (type === 'ADVERT' && tx.advert && tx.advert.name) {
|
||
line.appendChild(makeElement('span', 'rt-who', tx.advert.name));
|
||
if (tx.advert.role) line.appendChild(makeElement('span', 'rt-text text-muted', tx.advert.role));
|
||
} else {
|
||
line.appendChild(makeElement('span', 'rt-who', typeLabel(type)));
|
||
let extra = null;
|
||
if (type === 'GRP_TXT') {
|
||
extra = tx.channelHash ? `channel 0x${tx.channelHash}, no key here` : 'encrypted';
|
||
} else if (type === 'TXT_MSG') {
|
||
extra = 'encrypted';
|
||
} else if (type === 'ADVERT') {
|
||
extra = window.meshCoreDecoder ? 'name not decoded' : null;
|
||
}
|
||
if (extra) line.appendChild(makeElement('span', 'rt-text text-muted', extra));
|
||
}
|
||
}
|
||
return line;
|
||
}
|
||
|
||
function buildSecondLine(tx) {
|
||
const line = makeElement('span', 'rt-l2');
|
||
const inlineTime = makeElement('span', 'rt-time-inline', clockText(tx.t));
|
||
line.appendChild(inlineTime);
|
||
const p = tx.packet;
|
||
if (p) {
|
||
// A bare packet row already names its type on the first line.
|
||
const typeOnFirstLine = readingOf(tx) === 'packet' && !(tx.advert && tx.advert.name);
|
||
if (!typeOnFirstLine) appendFact(line, typeLabel(p.payload_type_name));
|
||
const route = routeInfo(p.route_type_name);
|
||
if (route) {
|
||
const r = makeElement('span', 'rt-route');
|
||
r.append(makeElement('span', `rt-dot rt-route-dot--${route.kind}`), document.createTextNode(route.label));
|
||
appendFact(line, r);
|
||
}
|
||
// SNR before hops: it's the reading a quick check wants, and on a
|
||
// phone the end of the line is what gets cut off.
|
||
const best = bestReception(tx);
|
||
appendFact(line, snrText(best ? best.snr : p.snr));
|
||
appendFact(line, hopsText(p.hops ?? p.path_len));
|
||
if (tx.receptions.length > 1) appendFact(line, makeElement('span', 'rt-heard', `heard ×${tx.receptions.length}`));
|
||
const path = pathList(p.path);
|
||
if (path.length) appendFact(line, makeElement('span', 'rt-path', path.join(' › ')));
|
||
if (tx.decoded && !tx.message) appendFact(line, 'decoded in this browser');
|
||
} else {
|
||
const src = tx.message || {};
|
||
if (tx.message) {
|
||
appendFact(line, snrText(src.snr));
|
||
appendFact(line, hopsText(src.hops));
|
||
}
|
||
}
|
||
if (line.firstChild === inlineTime && line.childNodes.length > 1) {
|
||
// The separator after the phone-only time is hidden with it on desktop.
|
||
line.childNodes[1].classList.add('rt-time-inline');
|
||
}
|
||
return line;
|
||
}
|
||
|
||
function repeatSummary(cmd, echoes) {
|
||
const count = Number(cmd.repeat_count) || 0;
|
||
if (count > 0) {
|
||
const prefixes = Array.isArray(cmd.repeater_prefixes) ? cmd.repeater_prefixes.map(String) : [];
|
||
const counts = cmd.repeater_counts || {};
|
||
const via = prefixes.map(prefix => {
|
||
const n = Number(counts[prefix]) || 1;
|
||
return n > 1 ? `${prefix} ×${n}` : prefix;
|
||
}).join(', ');
|
||
return `Heard back ×${count}${via ? ` via ${via}` : ''}`;
|
||
}
|
||
if (echoes.length) return `Heard back ×${echoes.length}`;
|
||
return 'Sent · not heard back yet';
|
||
}
|
||
|
||
function buildReply(tx) {
|
||
const cmd = tx.command;
|
||
const reply = makeElement('div', 'rt-reply');
|
||
const head = makeElement('div', 'rt-reply-head');
|
||
head.append(makeIcon('fas fa-robot'), makeElement('span', '', 'Bot reply'));
|
||
if (cmd.command) head.appendChild(makeElement('span', 'rt-mono', cmd.command));
|
||
reply.appendChild(head);
|
||
reply.appendChild(makeElement('p', 'rt-reply-text', cmd.response || 'No response'));
|
||
const failed = cmd.success === false;
|
||
const status = makeElement('div', `rt-reply-status${failed ? ' is-failed' : ''}`);
|
||
if (failed) {
|
||
status.append(makeIcon('fas fa-circle-exclamation'), document.createTextNode(' Didn\'t send'));
|
||
} else {
|
||
status.textContent = repeatSummary(cmd, tx.echoes);
|
||
}
|
||
reply.appendChild(status);
|
||
return reply;
|
||
}
|
||
|
||
function receptionTable(caption, list) {
|
||
const table = makeElement('table', 'rt-receptions');
|
||
table.appendChild(makeElement('caption', 'visually-hidden', caption));
|
||
const head = makeElement('tr');
|
||
['Time', 'SNR', 'RSSI', 'Hops', 'Path'].forEach(h => {
|
||
const th = makeElement('th', '', h);
|
||
th.scope = 'col';
|
||
head.appendChild(th);
|
||
});
|
||
const thead = makeElement('thead');
|
||
thead.appendChild(head);
|
||
const tbody = makeElement('tbody');
|
||
list.forEach(r => {
|
||
const row = makeElement('tr');
|
||
const snr = Number(r.snr);
|
||
const rssi = Number(r.rssi);
|
||
row.append(
|
||
makeElement('td', '', clockText(r.t)),
|
||
makeElement('td', '', Number.isFinite(snr) && r.snr !== null && r.snr !== '' ? `${snr.toFixed(1)} dB` : '—'),
|
||
makeElement('td', '', Number.isFinite(rssi) && r.rssi !== null && r.rssi !== '' ? `${rssi} dBm` : '—'),
|
||
makeElement('td', '', hopsText(r.hops).replace(/ hops?$/, '')),
|
||
makeElement('td', 'rt-path', r.path.length ? r.path.join(' › ') : '—')
|
||
);
|
||
tbody.appendChild(row);
|
||
});
|
||
table.append(thead, tbody);
|
||
return table;
|
||
}
|
||
|
||
function buildDetail(tx) {
|
||
const detail = makeElement('div', 'rt-detail');
|
||
detail.id = `rt-detail-${cssId(tx.key)}`;
|
||
detail.hidden = !tx.open;
|
||
if (!tx.open) return detail;
|
||
|
||
const reading = readingOf(tx);
|
||
// The first line truncates; the full text is only worth repeating when long.
|
||
const fullText = reading === 'command' ? tx.command.user_input
|
||
: reading === 'message' ? (tx.message || tx.decoded).content : null;
|
||
if (fullText && String(fullText).length > 60) detail.appendChild(makeElement('p', 'rt-detail-text', fullText));
|
||
|
||
const facts = makeElement('dl', 'rt-facts');
|
||
const fact = (label, value, mono) => {
|
||
if (value === null || value === undefined || value === '') return;
|
||
facts.append(makeElement('dt', '', label), makeElement('dd', mono ? 'rt-mono' : '', value));
|
||
};
|
||
const p = tx.packet;
|
||
fact('Packet hash', tx.hash, true);
|
||
if (p) {
|
||
fact('Type', typeLabel(p.payload_type_name));
|
||
const route = routeInfo(p.route_type_name);
|
||
fact('Route', route ? route.label : p.route_type_name);
|
||
const hops = p.hops ?? p.path_len;
|
||
if (p.path_byte_length) {
|
||
const bph = Number(p.bytes_per_hop) || 1;
|
||
fact('Path', `${hopsText(hops)} × ${bph} byte${bph === 1 ? '' : 's'} per hop`);
|
||
}
|
||
}
|
||
if (!p) {
|
||
fact('Packet', !tx.hash ? 'The bot didn\'t record which packet carried this'
|
||
: tx.lookup === 'none' ? 'Not in the bot\'s packet log, so route, path and receptions aren\'t shown'
|
||
: tx.lookup === 'failed' ? 'Couldn\'t load it. Close and reopen the row to try again'
|
||
: 'Loading…');
|
||
}
|
||
if (tx.message && tx.message.is_dm) fact('Channel', 'Direct message');
|
||
if (tx.decoded && !tx.message) fact('Source', 'Decrypted in this browser with a configured channel key');
|
||
else if (tx.message) fact('Source', 'Captured by the bot');
|
||
if (tx.command) fact('Command', tx.command.command, true);
|
||
detail.appendChild(facts);
|
||
|
||
if (tx.receptions.length) {
|
||
detail.appendChild(makeElement('div', 'rt-subhead', `Heard ${tx.receptions.length} time${tx.receptions.length === 1 ? '' : 's'}`));
|
||
detail.appendChild(receptionTable('Receptions of this packet', tx.receptions));
|
||
}
|
||
if (tx.echoes.length) {
|
||
detail.appendChild(makeElement('div', 'rt-subhead', `Reply heard back ${tx.echoes.length} time${tx.echoes.length === 1 ? '' : 's'}`));
|
||
detail.appendChild(receptionTable('Receptions of the bot\'s reply', tx.echoes));
|
||
}
|
||
|
||
const actions = makeElement('div', 'rt-detail-actions');
|
||
const withBytes = tx.receptions.find(r => r.data.raw_packet_hex || r.data.payload_hex);
|
||
if (withBytes) {
|
||
const inspect = makeElement('button', 'btn btn-sm btn-outline-primary');
|
||
inspect.type = 'button';
|
||
inspect.setAttribute('aria-haspopup', 'dialog');
|
||
setButtonIconAndLabel(inspect, 'fas fa-microchip', 'Inspect bytes');
|
||
inspect.addEventListener('click', () => showPacketDetail(withBytes.id, inspect));
|
||
actions.appendChild(inspect);
|
||
}
|
||
if (tx.hash) {
|
||
const copy = makeElement('button', 'btn btn-sm btn-outline-secondary');
|
||
copy.type = 'button';
|
||
setButtonIconAndLabel(copy, 'fas fa-copy', 'Copy hash');
|
||
copy.addEventListener('click', () => {
|
||
copyText(tx.hash).then(() => {
|
||
setButtonIconAndLabel(copy, 'fas fa-check', 'Copied');
|
||
setTimeout(() => setButtonIconAndLabel(copy, 'fas fa-copy', 'Copy hash'), 1500);
|
||
}).catch(() => {});
|
||
});
|
||
actions.appendChild(copy);
|
||
}
|
||
if (actions.childNodes.length) detail.appendChild(actions);
|
||
return detail;
|
||
}
|
||
|
||
function cssId(key) {
|
||
return String(key).replace(/[^A-Za-z0-9_-]/g, '_');
|
||
}
|
||
|
||
const TYPE_ICONS = {
|
||
GRP_TXT: 'fas fa-hashtag', TXT_MSG: 'fas fa-envelope', ADVERT: 'fas fa-bullhorn',
|
||
ACK: 'fas fa-check', PATH: 'fas fa-route', TRACE: 'fas fa-shoe-prints',
|
||
REQ: 'fas fa-arrow-right', RESPONSE: 'fas fa-reply', ANON_REQ: 'fas fa-user-secret',
|
||
GRP_DATA: 'fas fa-database', MULTIPART: 'fas fa-layer-group'
|
||
};
|
||
const TYPE_COLOR_KEYS = new Set(['GRP_TXT', 'RESPONSE', 'REQ', 'PATH', 'TXT_MSG', 'ANON_REQ', 'GRP_DATA', 'ADVERT']);
|
||
|
||
function rowType(tx) {
|
||
if (tx.packet && tx.packet.payload_type_name) return tx.packet.payload_type_name;
|
||
// A captured message without its packet is still a known type: channel
|
||
// messages travel as group text, DMs as direct messages.
|
||
if (tx.message) return tx.message.is_dm ? 'TXT_MSG' : 'GRP_TXT';
|
||
if (tx.command) return 'GRP_TXT';
|
||
return null;
|
||
}
|
||
|
||
function buildGlyph(tx) {
|
||
const reading = readingOf(tx);
|
||
const type = rowType(tx);
|
||
const colorKey = TYPE_COLOR_KEYS.has(type) ? type.toLowerCase() : 'other';
|
||
const chip = makeElement('span', `rt-glyph-chip rt-t-${colorKey}`);
|
||
let icon;
|
||
let label;
|
||
if (reading === 'command') {
|
||
chip.classList.add('is-bot');
|
||
icon = 'fas fa-robot';
|
||
label = 'Bot command';
|
||
} else if (reading === 'message') {
|
||
chip.classList.add('is-message');
|
||
icon = 'fas fa-comment';
|
||
label = type === 'TXT_MSG' ? 'Direct message' : 'Channel message';
|
||
} else {
|
||
icon = TYPE_ICONS[type] || 'fas fa-tower-broadcast';
|
||
label = `${typeLabel(type)} packet`;
|
||
}
|
||
chip.appendChild(makeIcon(icon));
|
||
chip.title = label;
|
||
const cell = makeElement('span', 'rt-glyph');
|
||
cell.append(chip, makeElement('span', 'visually-hidden', label));
|
||
return cell;
|
||
}
|
||
|
||
function renderTx(tx, change = 'update') {
|
||
let li = tx.el;
|
||
let refocus = null;
|
||
if (!li) {
|
||
li = makeElement('li', 'rt-row');
|
||
li.dataset.key = tx.key;
|
||
tx.el = li;
|
||
} else if (li.contains(document.activeElement)) {
|
||
refocus = document.activeElement.classList.contains('rt-keep') ? '.rt-keep' : '.rt-main';
|
||
}
|
||
li.classList.toggle('is-open', tx.open);
|
||
|
||
const detail = buildDetail(tx);
|
||
|
||
const main = makeElement('button', 'rt-main');
|
||
main.type = 'button';
|
||
main.setAttribute('aria-expanded', tx.open ? 'true' : 'false');
|
||
main.setAttribute('aria-controls', detail.id);
|
||
main.append(makeElement('span', 'rt-time', clockText(tx.t)), buildGlyph(tx), buildFirstLine(tx), buildSecondLine(tx));
|
||
main.addEventListener('click', () => {
|
||
tx.open = !tx.open;
|
||
renderTx(tx);
|
||
if (tx.open) lookUpPacket(tx);
|
||
});
|
||
|
||
const keep = makeElement('button', 'rt-keep');
|
||
keep.type = 'button';
|
||
keep.setAttribute('aria-pressed', tx.kept ? 'true' : 'false');
|
||
keep.setAttribute('aria-label', tx.kept ? 'Stop keeping this transmission' : 'Keep this transmission');
|
||
keep.title = tx.kept ? 'Kept: safe from the row limit and from Clear' : 'Keep it: safe from the row limit and from Clear';
|
||
keep.appendChild(makeIcon(tx.kept ? 'fas fa-star' : 'far fa-star'));
|
||
keep.addEventListener('click', () => toggleKeep(tx));
|
||
|
||
const line = makeElement('div', 'rt-line');
|
||
line.append(main, keep);
|
||
li.replaceChildren(line);
|
||
if (tx.command) li.appendChild(buildReply(tx));
|
||
li.appendChild(detail);
|
||
|
||
if (!li.isConnected) {
|
||
if (tx.kept) keptList.prepend(li);
|
||
else insertRow(tx);
|
||
}
|
||
li.hidden = !tx.kept && !matches(tx);
|
||
// A repeat only changes its count, so only the count lights up; a busy
|
||
// tape whose every row re-flashed on each echo never sat still.
|
||
const heard = change === 'update' ? li.querySelector('.rt-heard') : null;
|
||
if (heard) heard.classList.add('is-bumped');
|
||
else if (change === 'new' || change === 'update') flash(li);
|
||
if (refocus) li.querySelector(refocus)?.focus({ preventScroll: true });
|
||
if (change === 'new') enforceCap();
|
||
updateCounts();
|
||
}
|
||
|
||
function flash(li) {
|
||
if (li.hidden) return;
|
||
li.classList.remove('is-fresh');
|
||
void li.offsetWidth;
|
||
li.classList.add('is-fresh');
|
||
}
|
||
|
||
// ── Markers: what happened to the link and the radio, in tape order ──
|
||
|
||
const MAX_MARKERS = 20;
|
||
const GAP_SETTLE_MS = 3000; // time for a reconnect's replay to arrive
|
||
const GAP_QUIET_S = 15; // a covered blip shorter than this isn't worth a line
|
||
let lostAt = null; // server time the socket dropped
|
||
let gapWatch = null; // { from, to, earliest } while a reconnect's replay arrives
|
||
const gaps = []; // [from, to] spans the page can't speak for
|
||
|
||
function clockOf(t) {
|
||
return new Date(t * 1000).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
function whenOf(t) {
|
||
const d = new Date(t * 1000);
|
||
return d.toDateString() === new Date().toDateString()
|
||
? clockOf(t)
|
||
: d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
function insertMarker(t, text, extraClass) {
|
||
const li = makeElement('li', `rt-divider rt-marker ${extraClass || ''}`.trim(), text);
|
||
li.dataset.t = String(t);
|
||
let ref = null;
|
||
for (const child of tape.children) {
|
||
if (child === divider) {
|
||
ref = divider;
|
||
break;
|
||
}
|
||
const other = child.dataset.key ? txs.get(child.dataset.key) : null;
|
||
const childT = other ? other.t : (child.dataset.t ? Number(child.dataset.t) : null);
|
||
if (childT !== null && childT < t) {
|
||
ref = child;
|
||
break;
|
||
}
|
||
}
|
||
tape.insertBefore(li, ref);
|
||
const markers = tape.querySelectorAll('.rt-marker');
|
||
for (let i = markers.length - 1; i >= MAX_MARKERS; i--) markers[i].remove();
|
||
}
|
||
|
||
// The reconnect replays the newest 50 rows. If the oldest of them reaches
|
||
// back past the drop, nothing was missed; otherwise say which span wasn't.
|
||
function settleGap() {
|
||
const g = gapWatch;
|
||
gapWatch = null;
|
||
if (!g || g.from === null) return;
|
||
const covered = g.earliest === null || g.earliest <= g.from;
|
||
if (covered) {
|
||
if (g.to - g.from >= GAP_QUIET_S) {
|
||
insertMarker(g.from, `Link lost ${clockOf(g.from)}–${clockOf(g.to)} · nothing missed`, 'rt-marker--link');
|
||
}
|
||
return;
|
||
}
|
||
const missedTo = Math.min(g.earliest, g.to);
|
||
gaps.push([g.from, missedTo]);
|
||
insertMarker(g.from, `Link lost ${clockOf(g.from)}–${clockOf(g.to)} · anything heard ${clockOf(g.from)}–${clockOf(missedTo)} isn't shown`, 'rt-marker--gap');
|
||
}
|
||
|
||
function insertRow(tx) {
|
||
emptyRow.hidden = true;
|
||
if (tx.history && !divider) {
|
||
divider = makeElement('li', 'rt-divider', 'Heard before you opened this page');
|
||
tape.appendChild(divider);
|
||
}
|
||
let ref = null;
|
||
let inZone = !tx.history;
|
||
for (const child of tape.children) {
|
||
if (child === divider) {
|
||
if (!tx.history) {
|
||
ref = divider;
|
||
break;
|
||
}
|
||
inZone = true;
|
||
continue;
|
||
}
|
||
if (!inZone || !child.dataset.key) continue;
|
||
const other = txs.get(child.dataset.key);
|
||
if (other && other.t < tx.t) {
|
||
ref = child;
|
||
break;
|
||
}
|
||
}
|
||
tape.insertBefore(tx.el, ref);
|
||
}
|
||
|
||
function toggleKeep(tx) {
|
||
tx.kept = !tx.kept;
|
||
tx.el.remove();
|
||
renderTx(tx, 'none');
|
||
keptSection.hidden = keptList.children.length === 0;
|
||
document.getElementById('rt-kept-count').textContent = keptList.children.length ? `(${keptList.children.length})` : '';
|
||
tx.el.querySelector('.rt-keep')?.focus({ preventScroll: true });
|
||
}
|
||
|
||
// ── Filters ──
|
||
|
||
function searchText(tx) {
|
||
const parts = [tx.hash, channelOf(tx)];
|
||
if (tx.packet) parts.push(typeLabel(tx.packet.payload_type_name), pathList(tx.packet.path).join(' '));
|
||
if (tx.message) parts.push(tx.message.sender, tx.message.content);
|
||
if (tx.decoded) parts.push(tx.decoded.sender, tx.decoded.content);
|
||
if (tx.advert) parts.push(tx.advert.name);
|
||
if (tx.command) parts.push(tx.command.user, tx.command.user_input, tx.command.command, tx.command.response,
|
||
(tx.command.repeater_prefixes || []).join(' '));
|
||
tx.receptions.forEach(r => parts.push(r.path.join(' ')));
|
||
return parts.filter(Boolean).join(' ').toLowerCase();
|
||
}
|
||
|
||
function matches(tx) {
|
||
if (view.kind === 'message' && readingOf(tx) === 'packet') return false;
|
||
if (view.kind === 'command' && !tx.command) return false;
|
||
if (view.type && (!tx.packet || tx.packet.payload_type_name !== view.type)) return false;
|
||
if (view.channel && channelOf(tx) !== view.channel) return false;
|
||
if (view.q && !searchText(tx).includes(view.q)) return false;
|
||
return true;
|
||
}
|
||
|
||
function filtersActive() {
|
||
return view.kind !== 'all' || !!view.type || !!view.channel || !!view.q;
|
||
}
|
||
|
||
function applyFilters() {
|
||
tape.querySelectorAll('.rt-row').forEach(li => {
|
||
const tx = txs.get(li.dataset.key);
|
||
li.hidden = !tx || !matches(tx);
|
||
});
|
||
const active = filtersActive();
|
||
document.getElementById('rt-clear-filters').hidden = !active;
|
||
const n = [view.kind !== 'all', view.type, view.channel, view.q].filter(Boolean).length;
|
||
document.getElementById('rt-filters-active').textContent = n ? `(${n})` : '';
|
||
updateCounts();
|
||
}
|
||
|
||
function clearFilters() {
|
||
view.kind = 'all';
|
||
view.type = '';
|
||
view.channel = '';
|
||
view.q = '';
|
||
document.getElementById('rt-kind-all').checked = true;
|
||
document.getElementById('rt-type').value = '';
|
||
document.getElementById('rt-channel').value = '';
|
||
document.getElementById('rt-search').value = '';
|
||
applyFilters();
|
||
}
|
||
|
||
function updateCounts() {
|
||
const rows = tape.querySelectorAll('.rt-row');
|
||
let shown = 0;
|
||
rows.forEach(li => { if (!li.hidden) shown++; });
|
||
document.getElementById('rt-filter-count').textContent =
|
||
filtersActive() && rows.length ? `${shown} of ${rows.length} shown` : '';
|
||
if (divider) {
|
||
let anyHistory = false;
|
||
for (let el = divider.nextElementSibling; el; el = el.nextElementSibling) {
|
||
if (!el.hidden) { anyHistory = true; break; }
|
||
}
|
||
divider.hidden = !anyHistory;
|
||
}
|
||
updateEmpty(rows.length, shown);
|
||
}
|
||
|
||
function updateEmpty(total, shown) {
|
||
if (total > 0 && shown > 0) {
|
||
emptyRow.hidden = true;
|
||
return;
|
||
}
|
||
emptyRow.hidden = false;
|
||
if (emptyRow !== tape.firstElementChild) tape.prepend(emptyRow);
|
||
if (total > 0) {
|
||
const button = makeElement('button', 'btn btn-sm btn-outline-secondary', 'Clear filters');
|
||
button.type = 'button';
|
||
button.addEventListener('click', clearFilters);
|
||
emptyRow.replaceChildren(makeElement('div', '', 'No transmissions match these filters.'), button);
|
||
} else if (link.socket === 'none') {
|
||
emptyRow.textContent = 'Live updates aren\'t available here. Reload the page, or check that real-time is enabled in the Web Viewer settings.';
|
||
} else if (link.socket !== 'up' && !connectCount) {
|
||
emptyRow.textContent = 'Connecting to the bot…';
|
||
} else if (clearedAt !== null) {
|
||
emptyRow.textContent = `Tape cleared at ${clockText(clearedAt)}. New transmissions show up here as the radio hears them.`;
|
||
} else {
|
||
renderWaiting();
|
||
}
|
||
}
|
||
|
||
// First run: say how long the station has been listening, and after a
|
||
// while with nothing, where to look. Rewritten each second by the strip tick.
|
||
const QUIET_HINT_S = 600;
|
||
function renderWaiting() {
|
||
const v = link.verdict;
|
||
if (!v) {
|
||
emptyRow.textContent = 'Nothing heard yet. Transmissions show up here as the radio hears them.';
|
||
return;
|
||
}
|
||
const receiving = v.lamp === 'connected' || v.text === 'Radio can\'t transmit';
|
||
if (!receiving) {
|
||
emptyRow.textContent = v.text === 'Bot not running'
|
||
? 'Nothing heard yet. The bot isn\'t running, so nothing will arrive until it starts.'
|
||
: `Nothing heard yet. ${v.text}: nothing can arrive until that's fixed.`;
|
||
return;
|
||
}
|
||
const since = link.botStarted ?? coverageStart ?? nowServer();
|
||
const waited = Math.max(0, nowServer() - since);
|
||
const head = makeElement('div', 'rt-waiting',
|
||
`Listening since ${whenOf(since)} (${agoSpan(waited)}) · nothing heard yet.`);
|
||
const parts = [head];
|
||
if (waited >= QUIET_HINT_S) {
|
||
const hint = makeElement('div', 'rt-waiting-hint');
|
||
const radio = makeElement('a', '', 'Radio settings');
|
||
radio.href = '/radio';
|
||
const logs = makeElement('a', '', 'the Logs');
|
||
logs.href = '/logs';
|
||
hint.append(
|
||
`Nothing in ${agoSpan(waited)}. On a quiet mesh that can be normal; if you expected traffic, check the frequency and preset in `,
|
||
radio, ', or look in ', logs, '.'
|
||
);
|
||
parts.push(hint);
|
||
} else {
|
||
parts.push(makeElement('div', 'rt-waiting-hint', 'Transmissions show up here the moment the radio hears them.'));
|
||
}
|
||
emptyRow.replaceChildren(...parts);
|
||
}
|
||
|
||
function agoSpan(seconds) {
|
||
const m = Math.floor(seconds / 60);
|
||
if (m < 1) return `${Math.floor(seconds)}s`;
|
||
if (m < 60) return `${m} min`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return `${h} h ${m % 60} min`;
|
||
const d = Math.floor(h / 24);
|
||
return d === 1 ? '1 day' : `${d} days`;
|
||
}
|
||
|
||
function clearTape() {
|
||
Array.from(txs.values()).forEach(tx => { if (!tx.kept) removeTx(tx); });
|
||
tape.querySelectorAll('.rt-marker').forEach(li => li.remove());
|
||
clearedAt = nowServer();
|
||
updateCounts();
|
||
}
|
||
|
||
// ── Holding still while someone reads ──
|
||
|
||
function pointerHolds() {
|
||
return hold.pointer && Date.now() - lastPointerMove < POINTER_IDLE_MS;
|
||
}
|
||
|
||
function keyboardHolds() {
|
||
try {
|
||
return !!tape.querySelector(':focus-visible');
|
||
} catch (e) {
|
||
return false; // browsers without :focus-visible
|
||
}
|
||
}
|
||
|
||
function isHeld() {
|
||
return hold.paused || hold.scrolled || pointerHolds() || keyboardHolds();
|
||
}
|
||
|
||
// RX: a burst keeps the LED lit rather than strobing it. Under reduced
|
||
// motion it stays dark; "Last heard" says the same thing without flashing.
|
||
const RX_ON_MS = 140;
|
||
const rxLed = document.getElementById('rt-rx');
|
||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
let rxTimer = null;
|
||
function pulseRx() {
|
||
if (!rxLed || reducedMotion.matches) return;
|
||
rxLed.classList.add('is-on');
|
||
clearTimeout(rxTimer);
|
||
rxTimer = setTimeout(() => rxLed.classList.remove('is-on'), RX_ON_MS);
|
||
}
|
||
|
||
function ingest(kind, data) {
|
||
if (!data || typeof data !== 'object') return;
|
||
if (kind === 'packet' && !data.replayed) pulseRx();
|
||
if (isHeld()) {
|
||
pending.push({ kind, data });
|
||
if (pending.length > MAX_PENDING) {
|
||
pending.shift();
|
||
pendingDropped++;
|
||
}
|
||
renderPending();
|
||
return;
|
||
}
|
||
applyEvent(kind, data);
|
||
}
|
||
|
||
function applyEvent(kind, data) {
|
||
try {
|
||
if (kind === 'packet') applyPacket(data);
|
||
else if (kind === 'message') applyMessage(data);
|
||
else if (kind === 'command') applyCommand(data);
|
||
} catch (e) {
|
||
console.warn('Realtime: could not show an event', e);
|
||
}
|
||
}
|
||
|
||
function flush(force) {
|
||
if (!pending.length || (!force && isHeld())) return;
|
||
const batch = pending.splice(0, pending.length);
|
||
batch.forEach(item => applyEvent(item.kind, item.data));
|
||
pendingDropped = 0;
|
||
renderPending();
|
||
renderStrip();
|
||
}
|
||
|
||
function pendingRows() {
|
||
const keys = new Set();
|
||
pending.forEach(({ kind, data }) => {
|
||
const hash = normHash(kind === 'command' ? data.trigger_packet_hash : data.packet_hash);
|
||
if (kind === 'command' && data.command_id && commandIndex.has(String(data.command_id))) return;
|
||
if (hash && (txs.has('h:' + hash) || replyOwner.has(hash))) return;
|
||
keys.add(hash || `${kind}:${keys.size}`);
|
||
});
|
||
return keys.size;
|
||
}
|
||
|
||
function renderPending() {
|
||
const button = document.getElementById('rt-pending');
|
||
const n = pending.length ? pendingRows() : 0;
|
||
if (!pending.length) {
|
||
button.hidden = true;
|
||
return;
|
||
}
|
||
button.hidden = false;
|
||
// The trailing words drop on a phone, where the button must stay short.
|
||
button.replaceChildren(
|
||
document.createTextNode(n ? `${n} new` : 'Updates'),
|
||
makeElement('span', 'rt-pending-more', hold.paused ? ' waiting' : ' · show')
|
||
);
|
||
button.setAttribute('aria-label', `${n ? `${n} new` : 'Updates'}${hold.paused ? ' waiting' : ', show them'}`);
|
||
button.title = pendingDropped
|
||
? `${pendingDropped} older updates were dropped to keep the page light`
|
||
: 'Show what arrived while the tape was held';
|
||
}
|
||
|
||
function setPaused(paused) {
|
||
hold.paused = paused;
|
||
const button = document.getElementById('rt-pause');
|
||
button.setAttribute('aria-pressed', paused ? 'true' : 'false');
|
||
button.replaceChildren(
|
||
makeIcon(paused ? 'fas fa-play' : 'fas fa-pause'),
|
||
document.createTextNode(' '),
|
||
makeElement('span', 'rt-pause-label', paused ? 'Resume' : 'Pause')
|
||
);
|
||
button.title = paused ? 'Resume the tape (P)' : 'Pause the tape (P)';
|
||
button.setAttribute('aria-label', paused ? 'Resume the tape' : 'Pause the tape');
|
||
if (!paused) flush(false);
|
||
renderPending();
|
||
renderStrip();
|
||
}
|
||
|
||
function measureScroll() {
|
||
const stripBottom = strip.getBoundingClientRect().bottom;
|
||
hold.scrolled = tape.getBoundingClientRect().top < stripBottom - 4;
|
||
}
|
||
|
||
// ── Strip ──
|
||
|
||
function renderStrip() {
|
||
const lamp = document.getElementById('rt-lamp');
|
||
const state = document.getElementById('rt-state');
|
||
let lampClass = 'status-connected';
|
||
let text = 'Listening';
|
||
let title = '';
|
||
if (link.socket === 'none') {
|
||
lampClass = 'status-disconnected';
|
||
text = 'Live updates unavailable';
|
||
} else if (link.socket === 'connecting') {
|
||
lampClass = 'status-warning';
|
||
text = 'Connecting…';
|
||
} else if (link.socket === 'down') {
|
||
lampClass = 'status-warning';
|
||
text = 'Reconnecting to the bot…';
|
||
} else if (link.socket === 'kicked') {
|
||
lampClass = 'status-disconnected';
|
||
text = 'Disconnected by the server';
|
||
title = 'Your session ended. Reload the page to sign in again.';
|
||
} else if (link.verdict && link.verdict.lamp !== 'connected') {
|
||
// The socket only proves the viewer is up; the bot and its radio
|
||
// decide whether anything is being heard (base.html stationVerdict).
|
||
lampClass = `status-${link.verdict.lamp}`;
|
||
text = link.verdict.text;
|
||
title = link.verdict.title + (link.offline && link.offlineSince ? ` Since ${link.offlineSince}.` : '');
|
||
} else if (!link.verdict && link.zombie) {
|
||
lampClass = 'status-disconnected';
|
||
text = 'Radio not responding';
|
||
} else if (!link.verdict && link.offline) {
|
||
lampClass = 'status-warning';
|
||
text = 'Radio can\'t transmit';
|
||
title = 'Sends are failing. Packets may still be arriving.' + (link.offlineSince ? ` Since ${link.offlineSince}.` : '');
|
||
} else if (!link.verdict) {
|
||
// Until the first station poll answers, don't claim to be listening
|
||
lampClass = 'status-warning';
|
||
text = 'Checking the bot…';
|
||
} else if (hold.paused) {
|
||
text = 'Listening · paused';
|
||
}
|
||
lamp.className = `status-indicator ${lampClass}`;
|
||
if (state.textContent !== text) state.textContent = text;
|
||
state.title = title;
|
||
|
||
document.getElementById('rt-last').textContent = lastHeard === null ? 'nothing yet' : agoText(lastHeard);
|
||
if (!txs.size && !emptyRow.hidden && link.socket === 'up' && clearedAt === null) renderWaiting();
|
||
renderRate();
|
||
renderSpark();
|
||
}
|
||
|
||
function renderRate() {
|
||
const now = nowServer();
|
||
const start = Math.max(now - RATE_WINDOW_S, coverageStart ?? now);
|
||
const span = now - start;
|
||
const el = document.getElementById('rt-rate');
|
||
if (span < 20) {
|
||
el.textContent = '—';
|
||
el.title = 'Not enough time on the tape yet';
|
||
return;
|
||
}
|
||
let count = 0;
|
||
for (let i = heardTimes.length - 1; i >= 0 && heardTimes[i] >= start; i--) count++;
|
||
const perMin = count / (span / 60);
|
||
el.textContent = `${perMin < 10 ? perMin.toFixed(1) : Math.round(perMin)}/min`;
|
||
el.title = `Packets heard per minute over the last ${Math.round(span / 60) || 1} min`;
|
||
}
|
||
|
||
function renderSpark() {
|
||
const svg = document.getElementById('rt-spark');
|
||
const now = nowServer();
|
||
const counts = new Array(SPARK_BUCKETS).fill(0);
|
||
const start = now - SPARK_BUCKETS * SPARK_BUCKET_S;
|
||
while (heardTimes.length && heardTimes[0] < start - RATE_WINDOW_S) heardTimes.shift();
|
||
for (let i = heardTimes.length - 1; i >= 0 && heardTimes[i] >= start; i--) {
|
||
const b = Math.min(SPARK_BUCKETS - 1, Math.floor((heardTimes[i] - start) / SPARK_BUCKET_S));
|
||
counts[b]++;
|
||
}
|
||
const max = Math.max(1, ...counts);
|
||
const known = coverageStart ?? now;
|
||
const width = 120 / SPARK_BUCKETS;
|
||
const rects = [];
|
||
counts.forEach((c, i) => {
|
||
// Time before the page's first packet is unknown, not quiet: it
|
||
// gets a hairline instead of a bar.
|
||
const bucketStart = start + i * SPARK_BUCKET_S;
|
||
const bucketEnd = bucketStart + SPARK_BUCKET_S;
|
||
const unknown = bucketEnd < known
|
||
|| gaps.some(([from, to]) => bucketStart >= from && bucketEnd <= to);
|
||
const h = unknown ? 1 : (c ? Math.max(2, Math.round((c / max) * 22)) : 1);
|
||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||
rect.setAttribute('x', (i * width + 0.5).toFixed(2));
|
||
rect.setAttribute('y', String(24 - h));
|
||
rect.setAttribute('width', (width - 1).toFixed(2));
|
||
rect.setAttribute('height', String(h));
|
||
if (unknown) rect.setAttribute('class', 'is-unknown');
|
||
else if (i === SPARK_BUCKETS - 1) rect.setAttribute('class', 'is-now');
|
||
rects.push(rect);
|
||
});
|
||
svg.replaceChildren(...rects);
|
||
}
|
||
|
||
function readBannerState(detail) {
|
||
if (detail) {
|
||
link.offline = !!detail.radio_offline;
|
||
link.offlineSince = detail.radio_offline_since || null;
|
||
link.zombie = !!detail.radio_zombie;
|
||
link.verdict = window.stationVerdict ? window.stationVerdict(detail) : null;
|
||
link.botStarted = typeof detail.bot_started === 'number' ? detail.bot_started : null;
|
||
} else {
|
||
const offline = document.getElementById('offline-banner');
|
||
const zombie = document.getElementById('zombie-banner');
|
||
link.offline = !!offline && !offline.classList.contains('d-none');
|
||
link.zombie = !!zombie && !zombie.classList.contains('d-none');
|
||
const since = document.getElementById('offline-since-text');
|
||
link.offlineSince = since && since.getAttribute('datetime') ? since.textContent.trim() : null;
|
||
}
|
||
renderStrip();
|
||
}
|
||
|
||
// ── Wiring ──
|
||
|
||
window.addEventListener('banner-status', e => readBannerState(e.detail));
|
||
window.addEventListener('station-recovered', e => {
|
||
const d = e.detail || {};
|
||
const now = nowServer();
|
||
const since = d.since ? ` · ${d.what} ${clockOf(d.since)}–${clockOf(now)}` : '';
|
||
insertMarker(now, `${d.title}${since}`, 'rt-marker--recovered');
|
||
});
|
||
readBannerState(window.lastBannerStatus || null);
|
||
|
||
document.getElementById('rt-pause').addEventListener('click', () => setPaused(!hold.paused));
|
||
document.getElementById('rt-pending').addEventListener('click', () => {
|
||
if (hold.scrolled) {
|
||
const top = tape.getBoundingClientRect().top + window.scrollY - strip.offsetHeight - 16;
|
||
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||
hold.scrolled = false;
|
||
}
|
||
if (hold.paused) setPaused(false);
|
||
flush(true);
|
||
});
|
||
document.getElementById('rt-clear').addEventListener('click', clearTape);
|
||
document.getElementById('rt-clear-filters').addEventListener('click', clearFilters);
|
||
document.getElementById('rt-filters-toggle').addEventListener('click', function() {
|
||
const bar = document.getElementById('rt-filters');
|
||
const open = !bar.classList.contains('is-open');
|
||
bar.classList.toggle('is-open', open);
|
||
this.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
});
|
||
document.querySelectorAll('input[name="rt-kind"]').forEach(radio => {
|
||
radio.addEventListener('change', () => {
|
||
view.kind = radio.value;
|
||
applyFilters();
|
||
});
|
||
});
|
||
document.getElementById('rt-type').addEventListener('change', e => {
|
||
view.type = e.target.value;
|
||
applyFilters();
|
||
});
|
||
document.getElementById('rt-channel').addEventListener('change', e => {
|
||
view.channel = e.target.value;
|
||
applyFilters();
|
||
});
|
||
let searchTimer = null;
|
||
const search = document.getElementById('rt-search');
|
||
search.addEventListener('input', () => {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => {
|
||
view.q = search.value.trim().toLowerCase();
|
||
applyFilters();
|
||
}, 150);
|
||
});
|
||
search.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && search.value) {
|
||
search.value = '';
|
||
view.q = '';
|
||
applyFilters();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
||
const t = e.target;
|
||
const typing = t && (t.isContentEditable || /^(SELECT|TEXTAREA)$/.test(t.tagName)
|
||
|| (t.tagName === 'INPUT' && !/^(radio|checkbox|button|submit|reset)$/.test(t.type)));
|
||
if (typing) return;
|
||
if (document.querySelector('.modal.show')) return;
|
||
if (e.key === '/') {
|
||
e.preventDefault();
|
||
search.focus();
|
||
} else if (e.key === 'p' || e.key === 'P') {
|
||
e.preventDefault();
|
||
setPaused(!hold.paused);
|
||
}
|
||
});
|
||
|
||
[tape, keptList].forEach(list => {
|
||
list.addEventListener('pointermove', e => {
|
||
if (e.pointerType !== 'mouse') return;
|
||
hold.pointer = true;
|
||
lastPointerMove = Date.now();
|
||
});
|
||
list.addEventListener('pointerleave', () => {
|
||
hold.pointer = false;
|
||
flush(false);
|
||
});
|
||
});
|
||
tape.addEventListener('focusout', () => setTimeout(() => flush(false), 0));
|
||
|
||
let scrollQueued = false;
|
||
window.addEventListener('scroll', () => {
|
||
if (scrollQueued) return;
|
||
scrollQueued = true;
|
||
requestAnimationFrame(() => {
|
||
scrollQueued = false;
|
||
measureScroll();
|
||
if (!hold.scrolled) flush(false);
|
||
});
|
||
}, { passive: true });
|
||
|
||
setInterval(() => {
|
||
flush(false);
|
||
renderStrip();
|
||
}, 1000);
|
||
|
||
if (socket) {
|
||
const onConnect = () => {
|
||
if (link.socket === 'up') return;
|
||
link.socket = 'up';
|
||
connectCount++;
|
||
if (connectCount === 1 && coverageStart === null) coverageStart = nowServer();
|
||
if (connectCount > 1 && lostAt !== null) {
|
||
gapWatch = { from: lostAt, to: nowServer(), earliest: null };
|
||
lostAt = null;
|
||
setTimeout(settleGap, GAP_SETTLE_MS);
|
||
}
|
||
socket.emit('subscribe_commands');
|
||
socket.emit('subscribe_packets');
|
||
socket.emit('subscribe_messages');
|
||
initializeKeyStore();
|
||
renderStrip();
|
||
updateCounts();
|
||
};
|
||
socket.on('connect', onConnect);
|
||
if (socket.connected) onConnect();
|
||
socket.on('disconnect', () => {
|
||
if (link.socket === 'up' && lostAt === null) lostAt = nowServer();
|
||
if (link.socket !== 'kicked') link.socket = 'down';
|
||
renderStrip();
|
||
});
|
||
socket.on('force_disconnect', () => {
|
||
link.socket = 'kicked';
|
||
renderStrip();
|
||
socket.disconnect();
|
||
});
|
||
socket.on('error', data => console.error('Socket error:', data && data.message));
|
||
socket.on('packet_data', data => ingest('packet', data));
|
||
socket.on('message_data', data => ingest('message', data));
|
||
socket.on('command_data', data => ingest('command', data));
|
||
}
|
||
decoderReady.then(() => decodePendingRows());
|
||
renderStrip();
|
||
updateCounts();
|
||
|
||
// Show packet detail modal
|
||
async function showPacketDetail(packetId, opener = null) {
|
||
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. Opened from script, Bootstrap doesn't return focus
|
||
// on close, so send it back to the row that opened it.
|
||
const modalEl = document.getElementById('packetDetailModal');
|
||
if (opener) {
|
||
modalEl.addEventListener('hidden.bs.modal', () => {
|
||
if (opener.isConnected) opener.focus();
|
||
}, { once: true });
|
||
}
|
||
const modal = new bootstrap.Modal(modalEl);
|
||
modal.show();
|
||
}
|
||
// Make showPacketDetail accessible globally
|
||
window.showPacketDetail = showPacketDetail;
|
||
|
||
// Path bytes on the wire: the server's count first, since the hop count
|
||
// and the byte count differ for multibyte paths.
|
||
function pathByteLength(decoded, serverData) {
|
||
if (serverData.path_byte_length !== null && Number.isFinite(Number(serverData.path_byte_length))) {
|
||
return Number(serverData.path_byte_length);
|
||
}
|
||
const hops = Number(serverData.path_len ?? decoded?.pathLength ?? 0) || 0;
|
||
return hops * (Number(serverData.bytes_per_hop) || 1);
|
||
}
|
||
|
||
// 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. Multibyte paths pack 2 or 3 bytes per hop, so the
|
||
// byte count, not the hop count, says where the payload starts.
|
||
const pathLen = pathByteLength(decoded, serverData);
|
||
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 = getPayloadTypeName(headerFields(rawHex, decoded, serverData).payloadType);
|
||
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`;
|
||
modalSegmentOffsets = segmentOffsets(decoded, serverData);
|
||
|
||
// Packet hash - the server's is the one rows, search and Copy hash use;
|
||
// the decoder's packetHash is the same firmware hash.
|
||
const packetHash = serverData.packet_hash || decoded?.packetHash?.toUpperCase();
|
||
document.getElementById('detail-packet-hash').textContent = packetHash || '—';
|
||
|
||
// Timestamp
|
||
const timestamp = serverData.datetime
|
||
? new Date(serverData.datetime).toLocaleString()
|
||
: new Date().toLocaleString();
|
||
document.getElementById('detail-timestamp').textContent = timestamp;
|
||
|
||
// Type and route from the header byte itself
|
||
const header = headerFields(rawHex, decoded, serverData);
|
||
const payloadTypeNum = header.payloadType;
|
||
const payloadType = getPayloadTypeName(header.payloadType);
|
||
document.getElementById('detail-payload-type').textContent = payloadType;
|
||
document.getElementById('detail-route-type').textContent = getRouteTypeName(header.routeType);
|
||
|
||
// Path - prefer decoder output
|
||
// The bot's own decode of the path first; the browser decode is the fallback.
|
||
let pathDisplay = 'None (heard first-hand)';
|
||
if (Array.isArray(serverData.path) && serverData.path.length > 0) {
|
||
pathDisplay = serverData.path.join(' › ');
|
||
} else if (decoded?.path && decoded.path.length > 0) {
|
||
pathDisplay = decoded.path.join(' › ');
|
||
}
|
||
document.getElementById('detail-path').textContent = pathDisplay;
|
||
|
||
// Hex breakdown with color coding
|
||
renderHexBreakdown(rawHex, structure);
|
||
|
||
// Header analysis
|
||
populateHeaderAnalysis(decoded, serverData, rawHex);
|
||
|
||
// 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))
|
||
);
|
||
}
|
||
|
||
if (container.childNodes.length) container.appendChild(document.createTextNode(' '));
|
||
|
||
// 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, rawHex) {
|
||
const { byte, routeType, payloadType, version } = headerFields(rawHex, decoded, serverData);
|
||
|
||
let headerByte = byte;
|
||
if (headerByte !== undefined) {
|
||
// read from the wire
|
||
} else 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', 'bits-binary', 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
|
||
let modalSegmentOffsets = null;
|
||
|
||
// Route, payload type and version straight from the header byte on the
|
||
// wire, so the breakdown always matches the bytes shown above it.
|
||
function headerFields(rawHex, decoded, serverData) {
|
||
const offsets = segmentOffsets(decoded, serverData);
|
||
const byte = parseInt(String(rawHex || '').slice(offsets.header * 2, offsets.header * 2 + 2), 16);
|
||
if (Number.isFinite(byte)) {
|
||
return { byte, routeType: byte & 0x03, payloadType: (byte >> 2) & 0x0f, version: (byte >> 6) & 0x03 };
|
||
}
|
||
return {
|
||
byte: undefined,
|
||
routeType: serverData.route_type ?? decoded?.routeType,
|
||
payloadType: serverData.payload_type ?? decoded?.payloadType,
|
||
version: serverData.payload_version ?? decoded?.payloadVersion
|
||
};
|
||
}
|
||
|
||
function byteRange(first, last, prefix = '') {
|
||
const word = prefix ? `${prefix} byte` : 'Byte';
|
||
return first === last ? `${word} ${first}` : `${word}s ${first}–${last}`;
|
||
}
|
||
|
||
// Where the header, path-length byte and path start: transport codes, when
|
||
// present, take the first four bytes.
|
||
function segmentOffsets(decoded, serverData) {
|
||
const hasTransport = decoded?.hasTransport ?? serverData.has_transport_codes ?? false;
|
||
const header = hasTransport ? 4 : 0;
|
||
return { header, pathLen: header + 1, path: header + 2 };
|
||
}
|
||
|
||
function hexBytesSpaced(hex, maxBytes) {
|
||
const bytes = String(hex || '').toUpperCase().match(/.{1,2}/g) || [];
|
||
const shown = maxBytes && bytes.length > maxBytes ? bytes.slice(0, maxBytes) : bytes;
|
||
const more = bytes.length - shown.length;
|
||
return shown.join(' ') + (more > 0 ? ` … (+${more} bytes)` : '');
|
||
}
|
||
|
||
// Populate path and payload sections
|
||
function populatePathAndPayload(decoded, serverData, structure) {
|
||
const offsets = modalSegmentOffsets || segmentOffsets(decoded, serverData);
|
||
document.getElementById('header-bytes').textContent = byteRange(offsets.header, offsets.header);
|
||
document.getElementById('pathlen-bytes').textContent = byteRange(offsets.pathLen, offsets.pathLen);
|
||
|
||
// Path length: the raw wire byte, and what it encodes
|
||
const pathLen = pathByteLength(decoded, serverData);
|
||
const hops = Number(serverData.path_len ?? decoded?.pathLength ?? 0) || 0;
|
||
const bytesPerHop = Number(serverData.bytes_per_hop) || 1;
|
||
const hasWireByte = serverData.path_len_byte !== null && serverData.path_len_byte !== undefined
|
||
&& Number.isFinite(Number(serverData.path_len_byte));
|
||
const wireByte = hasWireByte ? Number(serverData.path_len_byte) : pathLen;
|
||
document.getElementById('pathlen-value').textContent = `0x${wireByte.toString(16).padStart(2, '0').toUpperCase()}`;
|
||
const hopWord = `${hops} hop${hops === 1 ? '' : 's'}`;
|
||
const sizeWord = `${bytesPerHop}-byte hash${bytesPerHop === 1 ? '' : 'es'}`;
|
||
// MeshCore packs the hop count into bits 0–5 and the hash size minus
|
||
// one into bits 6–7; say so only when the byte agrees with the decode.
|
||
const bitsAgree = hasWireByte && (wireByte & 0x3f) === hops && ((wireByte >> 6) + 1) === bytesPerHop;
|
||
let pathLenText;
|
||
if (pathLen === 0) {
|
||
pathLenText = 'No path bytes: heard first-hand, or sent on a direct route';
|
||
} else if (bitsAgree && bytesPerHop > 1) {
|
||
pathLenText = `${hopWord} (bits 0–5) of ${sizeWord} (bits 6–7) = ${pathLen} bytes of path`;
|
||
} else {
|
||
pathLenText = `${hopWord} × ${sizeWord} = ${pathLen} bytes of path`;
|
||
}
|
||
document.getElementById('pathlen-description').textContent = pathLenText;
|
||
|
||
// Path data, one hop per group
|
||
let hopsList = [];
|
||
if (Array.isArray(serverData.path) && serverData.path.length > 0) {
|
||
hopsList = serverData.path.map(String);
|
||
} else if (decoded?.path && decoded.path.length > 0) {
|
||
hopsList = decoded.path.map(String);
|
||
} else if (serverData.path_hex) {
|
||
hopsList = String(serverData.path_hex).match(new RegExp(`.{1,${bytesPerHop * 2}}`, 'g')) || [];
|
||
}
|
||
document.getElementById('path-value').textContent = hopsList.length ? hopsList.join(' ').toUpperCase() : 'None';
|
||
document.getElementById('path-bytes').textContent = pathLen > 0
|
||
? byteRange(offsets.path, offsets.path + pathLen - 1)
|
||
: 'No bytes';
|
||
const route = String(serverData.route_type_name || '').toUpperCase();
|
||
document.getElementById('path-description').textContent = pathLen === 0
|
||
? ''
|
||
: route.includes('DIRECT')
|
||
? 'The route this packet is still to follow, one hash per hop'
|
||
: 'Repeaters it passed through, one hash prefix added per hop as it flooded';
|
||
|
||
// Payload
|
||
const payloadHex = serverData.payload_hex || decoded?.payload?.raw || '';
|
||
const payloadStart = offsets.path + pathLen;
|
||
const totalBytes = window.currentPacketHex ? window.currentPacketHex.length / 2 : 0;
|
||
document.getElementById('payload-bytes').textContent = totalBytes > payloadStart
|
||
? byteRange(payloadStart, totalBytes - 1)
|
||
: 'No bytes';
|
||
document.getElementById('payload-value').textContent = hexBytesSpaced(payloadHex, 48) || 'None';
|
||
|
||
const payloadTypeName = getPayloadTypeName(headerFields(window.currentPacketHex, decoded, serverData).payloadType);
|
||
document.getElementById('payload-description').textContent = `${payloadTypeName} payload`;
|
||
}
|
||
|
||
// Populate GroupText breakdown using decoded data
|
||
async function populateGroupTextBreakdown(decoded, serverData, structure, fullPacketHex) {
|
||
const payloadHex = serverData.payload_hex || decoded?.payload?.raw || '';
|
||
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 = hexBytesSpaced(cipherMAC);
|
||
|
||
// Ciphertext
|
||
document.getElementById('ciphertext-value').textContent = hexBytesSpaced(ciphertext, 40);
|
||
document.getElementById('ciphertext-bytes').textContent = byteRange(3, 2 + (ciphertext.length / 2), 'Payload');
|
||
|
||
// 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: 'TransportFlood',
|
||
1: 'Flood',
|
||
2: 'Direct',
|
||
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) {
|
||
copyText(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 => {
|
||
// Say so instead of failing silently.
|
||
console.error('Failed to copy:', err);
|
||
const btn = document.getElementById('copy-raw-packet');
|
||
setButtonIconAndLabel(btn, 'fas fa-triangle-exclamation', 'Copy blocked by the browser');
|
||
setTimeout(() => {
|
||
setButtonIconAndLabel(btn, 'fas fa-copy', 'Copy raw hex');
|
||
}, 2500);
|
||
});
|
||
}
|
||
}
|
||
|
||
document.getElementById('copy-raw-packet').addEventListener('click', copyRawPacket);
|
||
</script>
|
||
{% endblock %}
|