diff --git a/frontend/css/dashboard.css b/frontend/css/dashboard.css
index dce60d6..a7f518e 100644
--- a/frontend/css/dashboard.css
+++ b/frontend/css/dashboard.css
@@ -469,16 +469,6 @@ html, body {
.packet-row { cursor: pointer; }
-.packet-detail-row td {
- padding: 0.5rem 0.75rem !important;
- font-size: 0.7rem;
- font-family: var(--font-mono);
- color: var(--text-secondary);
- white-space: pre-wrap;
- word-break: break-word;
- background: var(--bg-secondary);
-}
-
.packet-count {
background: rgba(6, 182, 212, 0.15);
color: var(--accent-cyan);
diff --git a/frontend/css/packet_detail_modal.css b/frontend/css/packet_detail_modal.css
new file mode 100644
index 0000000..a04964a
--- /dev/null
+++ b/frontend/css/packet_detail_modal.css
@@ -0,0 +1,161 @@
+/* Packet detail modal — layered breakdown of a live-feed packet. */
+
+.pdm-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1100;
+ padding: 1rem;
+}
+
+.pdm-modal {
+ background: var(--bg-primary, #0f1419);
+ border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
+ border-radius: 12px;
+ width: min(640px, 100%);
+ max-height: min(85vh, 720px);
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.55);
+ overflow: hidden;
+}
+
+.pdm-modal__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.85rem 1rem;
+ border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
+ flex-shrink: 0;
+}
+
+.pdm-modal__title {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-primary, #e8edf2);
+ margin: 0;
+}
+
+.pdm-modal__meta {
+ font-family: var(--font-mono);
+ font-size: 0.65rem;
+ color: var(--text-secondary, #8b9aab);
+}
+
+.pdm-modal__close {
+ background: none;
+ border: none;
+ color: var(--text-secondary, #8b9aab);
+ font-size: 1.35rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0.15rem 0.35rem;
+ border-radius: 6px;
+}
+
+.pdm-modal__close:hover {
+ color: var(--accent-cyan, #06b6d4);
+ background: rgba(6, 182, 212, 0.1);
+}
+
+.pdm-modal__body {
+ overflow-y: auto;
+ padding: 0.75rem 1rem 1rem;
+}
+
+.pdm-layer {
+ display: grid;
+ grid-template-columns: 7.5rem 1fr;
+ gap: 0.35rem 0.75rem;
+ padding: 0.65rem 0;
+ border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
+ font-size: 0.78rem;
+}
+
+.pdm-layer:last-child {
+ border-bottom: none;
+}
+
+.pdm-layer__label {
+ font-weight: 600;
+ color: var(--accent-cyan, #06b6d4);
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding-top: 0.1rem;
+}
+
+.pdm-layer__rows {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.pdm-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+ line-height: 1.45;
+}
+
+.pdm-row__key {
+ color: var(--text-secondary, #8b9aab);
+ flex-shrink: 0;
+}
+
+.pdm-row__val {
+ color: var(--text-primary, #e8edf2);
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+ word-break: break-word;
+}
+
+.pdm-row__val--good {
+ color: #44d39a;
+}
+
+.pdm-row__val--bad {
+ color: #f87171;
+}
+
+.pdm-payload-text {
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text-primary, #e8edf2);
+ margin: 0;
+}
+
+.pdm-expand {
+ margin-top: 0.35rem;
+ background: none;
+ border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
+ color: var(--accent-cyan, #06b6d4);
+ font-size: 0.68rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.pdm-expand:hover {
+ background: rgba(6, 182, 212, 0.08);
+}
+
+.packet-row--selected {
+ background: rgba(6, 182, 212, 0.08);
+}
+
+@media (max-width: 520px) {
+ .pdm-layer {
+ grid-template-columns: 1fr;
+ gap: 0.35rem;
+ }
+}
diff --git a/frontend/index.html b/frontend/index.html
index 239398c..2402201 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -36,6 +36,7 @@
+
@@ -681,6 +682,7 @@
Service actions
+
diff --git a/frontend/js/packet_detail_modal.js b/frontend/js/packet_detail_modal.js
new file mode 100644
index 0000000..ee0c013
--- /dev/null
+++ b/frontend/js/packet_detail_modal.js
@@ -0,0 +1,340 @@
+/**
+ * Modal breakdown of a single packet from the live WebSocket feed.
+ * Reads only the in-memory packet object — no extra API calls.
+ */
+class PacketDetailModal {
+ constructor() {
+ this._overlay = null;
+ this._selectedRow = null;
+ this._onClose = null;
+ this._onKeyDown = this._onKeyDown.bind(this);
+ }
+
+ /**
+ * @param {object} packet — WebSocket packet payload
+ * @param {{ formatNodeId?: function, onClose?: function, selectedRow?: HTMLElement }} opts
+ */
+ show(packet, opts = {}) {
+ this.close({ skipCallback: true });
+
+ this._onClose = opts.onClose || null;
+ this._selectedRow = opts.selectedRow || null;
+ if (this._selectedRow) {
+ this._selectedRow.classList.add('packet-row--selected');
+ }
+
+ const overlay = document.createElement('div');
+ overlay.className = 'pdm-overlay';
+ overlay.setAttribute('role', 'dialog');
+ overlay.setAttribute('aria-modal', 'true');
+ overlay.setAttribute('aria-label', 'Packet detail');
+
+ const modal = document.createElement('div');
+ modal.className = 'pdm-modal';
+ modal.addEventListener('click', (e) => e.stopPropagation());
+
+ const timeLabel = this._formatTime(packet);
+ modal.innerHTML = `
+
+
+ `;
+
+ const body = modal.querySelector('.pdm-modal__body');
+ body.appendChild(this._buildLayer('RF', this._rfRows(packet)));
+ body.appendChild(this._buildLayer('Mesh', this._meshRows(packet, opts.formatNodeId)));
+ body.appendChild(this._buildLayer('Payload', this._payloadRows(packet)));
+ body.appendChild(this._buildLayer('Capture', this._captureRows(packet)));
+
+ modal.querySelector('.pdm-modal__close').addEventListener('click', () => this.close());
+ overlay.addEventListener('click', () => this.close());
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+ this._overlay = overlay;
+
+ document.addEventListener('keydown', this._onKeyDown);
+ modal.querySelector('.pdm-modal__close').focus();
+ }
+
+ close(opts = {}) {
+ document.removeEventListener('keydown', this._onKeyDown);
+ if (this._overlay) {
+ this._overlay.remove();
+ this._overlay = null;
+ }
+ if (this._selectedRow) {
+ this._selectedRow.classList.remove('packet-row--selected');
+ this._selectedRow = null;
+ }
+ const cb = this._onClose;
+ this._onClose = null;
+ if (!opts.skipCallback && cb) cb();
+ }
+
+ _onKeyDown(e) {
+ if (e.key === 'Escape') this.close();
+ }
+
+ _buildLayer(label, rows) {
+ const layer = document.createElement('section');
+ layer.className = 'pdm-layer';
+ const rowsEl = document.createElement('div');
+ rowsEl.className = 'pdm-layer__rows';
+ for (const row of rows) {
+ if (row.html) {
+ rowsEl.insertAdjacentHTML('beforeend', row.html);
+ } else if (row.expandable) {
+ rowsEl.appendChild(this._expandableBlock(row.key, row.full, row.previewLen));
+ } else {
+ rowsEl.appendChild(this._row(row.key, row.val, row.valClass));
+ }
+ }
+ layer.innerHTML = `${this._esc(label)}
`;
+ layer.appendChild(rowsEl);
+ return layer;
+ }
+
+ _row(key, val, valClass) {
+ const row = document.createElement('div');
+ row.className = 'pdm-row';
+ const cls = valClass ? ` pdm-row__val--${valClass}` : '';
+ row.innerHTML = `
+ ${this._esc(key)}:
+ ${this._esc(val)}
+ `;
+ return row;
+ }
+
+ _expandableBlock(key, fullText, previewLen) {
+ const wrap = document.createElement('div');
+ wrap.className = 'pdm-row';
+ const needsToggle = fullText.length > previewLen;
+ const preview = needsToggle ? fullText.slice(0, previewLen) + '…' : fullText;
+
+ const keySpan = document.createElement('span');
+ keySpan.className = 'pdm-row__key';
+ keySpan.textContent = `${key}:`;
+
+ const pre = document.createElement('pre');
+ pre.className = 'pdm-payload-text';
+ pre.textContent = preview;
+
+ wrap.appendChild(keySpan);
+ wrap.appendChild(pre);
+
+ if (needsToggle) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'pdm-expand';
+ btn.textContent = 'Show more';
+ let expanded = false;
+ btn.addEventListener('click', () => {
+ expanded = !expanded;
+ pre.textContent = expanded ? fullText : preview;
+ btn.textContent = expanded ? 'Show less' : 'Show more';
+ });
+ wrap.appendChild(btn);
+ }
+ return wrap;
+ }
+
+ _rfRows(packet) {
+ const sig = packet.signal || {};
+ const rssi = sig.rssi != null ? sig.rssi : packet.rssi;
+ const snr = sig.snr != null ? sig.snr : packet.snr;
+ const freq = sig.frequency_mhz != null ? sig.frequency_mhz : packet.frequency_mhz;
+ const sf = sig.spreading_factor != null ? sig.spreading_factor : packet.spreading_factor;
+ const bw = sig.bandwidth_khz != null ? sig.bandwidth_khz : packet.bandwidth_khz;
+ const cr = sig.coding_rate || packet.coding_rate;
+
+ const modemParts = [];
+ if (sf != null) modemParts.push(`SF${sf}`);
+ if (bw != null) modemParts.push(`BW${Number(bw)}`);
+ if (cr) modemParts.push(`CR ${cr}`);
+
+ return [
+ { key: 'Frequency', val: freq != null ? `${Number(freq).toFixed(3)} MHz` : '—' },
+ { key: 'Modem', val: modemParts.length ? modemParts.join(' · ') : '—' },
+ {
+ key: 'RSSI',
+ val: rssi != null ? `${Number(rssi).toFixed(0)} dBm` : '—',
+ },
+ {
+ key: 'SNR',
+ val: snr != null ? `${Number(snr).toFixed(1)} dB` : '—',
+ },
+ ];
+ }
+
+ _meshRows(packet, formatNodeId) {
+ const fmt = typeof formatNodeId === 'function' ? formatNodeId : (id) => id || '—';
+ const hopsTaken = packet.hop_count != null
+ ? packet.hop_count
+ : (packet.hop_start > 0 ? packet.hop_start - packet.hop_limit : null);
+ const hopLabel = packet.hop_start > 0
+ ? `${hopsTaken != null ? hopsTaken : '?' } hop${hopsTaken === 1 ? '' : 's'} taken (${packet.hop_limit} left of ${packet.hop_start})`
+ : '—';
+
+ const rows = [
+ { key: 'From', val: `${fmt(packet.source_id)} (${packet.source_id || '—'})` },
+ { key: 'To', val: `${fmt(packet.destination_id)} (${packet.destination_id || '—'})` },
+ { key: 'Type', val: packet.packet_type || '—' },
+ { key: 'Protocol', val: packet.protocol || '—' },
+ { key: 'Hops', val: hopLabel },
+ { key: 'Want ACK', val: packet.want_ack ? 'Yes' : 'No' },
+ ];
+
+ if (packet.channel_hash != null && packet.channel_hash !== 0) {
+ rows.push({
+ key: 'Channel hash',
+ val: `0x${Number(packet.channel_hash).toString(16).padStart(2, '0')}`,
+ });
+ }
+ if (packet.relay_node) {
+ rows.push({
+ key: 'Relay byte',
+ val: `0x${Number(packet.relay_node).toString(16).padStart(2, '0')}`,
+ });
+ }
+ if (packet.via_mqtt) {
+ rows.push({ key: 'Via MQTT', val: 'Yes' });
+ }
+
+ const portnum = packet.decoded_payload && packet.decoded_payload.portnum;
+ if (portnum != null) {
+ rows.push({ key: 'Portnum', val: String(portnum) });
+ }
+
+ return rows;
+ }
+
+ _payloadRows(packet) {
+ const p = packet.decoded_payload;
+ const type = packet.packet_type || 'unknown';
+ const decrypted = packet.decrypted !== false && type !== 'encrypted';
+
+ if (!decrypted || type === 'encrypted') {
+ const hex = (p && p.raw_hex) ? p.raw_hex : null;
+ const rows = [
+ {
+ key: 'Decrypt',
+ val: 'No matching key',
+ valClass: 'bad',
+ },
+ { key: 'Channel', val: this._channelLabel(packet) },
+ ];
+ if (hex) {
+ rows.push({
+ key: 'Raw bytes',
+ expandable: true,
+ full: hex,
+ previewLen: 120,
+ });
+ }
+ return rows;
+ }
+
+ const rows = [
+ { key: 'Decrypt', val: 'Success', valClass: 'good' },
+ { key: 'Channel', val: this._channelLabel(packet) },
+ ];
+
+ const summary = this._payloadSummary(packet);
+ if (summary) {
+ rows.push({ key: 'Content', val: summary });
+ } else if (p && typeof p === 'object') {
+ rows.push({
+ key: 'JSON',
+ expandable: true,
+ full: JSON.stringify(p, null, 2),
+ previewLen: 480,
+ });
+ } else {
+ rows.push({ key: 'Content', val: '—' });
+ }
+
+ return rows;
+ }
+
+ _captureRows(packet) {
+ return [
+ { key: 'Packet ID', val: packet.packet_id || '—' },
+ { key: 'Source', val: packet.capture_source || '—' },
+ { key: 'Timestamp', val: packet.timestamp || '—' },
+ ];
+ }
+
+ _channelLabel(packet) {
+ if (packet.channel_hash != null && packet.channel_hash !== 0) {
+ return `hash 0x${Number(packet.channel_hash).toString(16).padStart(2, '0')}`;
+ }
+ const name = packet.decoded_payload && packet.decoded_payload.channel;
+ return name || '(unknown)';
+ }
+
+ _payloadSummary(packet) {
+ const p = packet.decoded_payload;
+ if (!p || typeof p !== 'object') return '';
+
+ switch (packet.packet_type) {
+ case 'text':
+ return p.text || '';
+ case 'position': {
+ const parts = [];
+ if (p.latitude != null) parts.push(`${Number(p.latitude).toFixed(5)}°`);
+ if (p.longitude != null) parts.push(`${Number(p.longitude).toFixed(5)}°`);
+ if (p.altitude != null) parts.push(`alt ${p.altitude} m`);
+ return parts.join(', ');
+ }
+ case 'nodeinfo':
+ return [p.long_name, p.short_name, p.hw_model].filter(Boolean).join(' · ');
+ case 'telemetry': {
+ const parts = [];
+ if (p.battery_level != null) parts.push(`battery ${p.battery_level}%`);
+ if (p.voltage != null) parts.push(`${Number(p.voltage).toFixed(2)} V`);
+ if (p.temperature != null) {
+ const t = window.MeshpointDisplayUnits
+ ? window.MeshpointDisplayUnits.formatTemperature(p.temperature)
+ : `${Number(p.temperature).toFixed(0)}°C`;
+ if (t) parts.push(t);
+ }
+ return parts.join(' · ');
+ }
+ case 'routing':
+ return p.error_reason || p.reply_id != null ? `reply ${p.reply_id}` : '';
+ case 'traceroute': {
+ const route = Array.isArray(p.route) ? p.route.join(' → ') : '';
+ return route ? `route: ${route}` : '';
+ }
+ case 'neighborinfo': {
+ const n = Array.isArray(p.neighbors) ? p.neighbors.length : 0;
+ return n ? `${n} neighbor(s) reported` : '';
+ }
+ default:
+ return '';
+ }
+ }
+
+ _formatTime(packet) {
+ if (packet.rx_time) {
+ return new Date(packet.rx_time * 1000).toLocaleString();
+ }
+ if (packet.timestamp) {
+ return new Date(packet.timestamp).toLocaleString();
+ }
+ return new Date().toLocaleString();
+ }
+
+ _esc(str) {
+ const el = document.createElement('span');
+ el.textContent = str == null ? '' : String(str);
+ return el.innerHTML;
+ }
+}
+
+window.PacketDetailModal = new PacketDetailModal();
diff --git a/frontend/js/simple_packet_feed.js b/frontend/js/simple_packet_feed.js
index 7088623..e89822e 100644
--- a/frontend/js/simple_packet_feed.js
+++ b/frontend/js/simple_packet_feed.js
@@ -1,6 +1,6 @@
/**
* Simple live packet feed for the local Meshpoint dashboard.
- * Renders incoming packets via WebSocket with expand-on-click.
+ * Renders incoming packets via WebSocket; row click opens PacketDetailModal.
*/
class SimplePacketFeed {
constructor(tbodyId, maxRows) {
@@ -27,7 +27,7 @@ class SimplePacketFeed {
addPacket(packet) {
const tr = document.createElement('tr');
- tr.classList.add('packet-row--new');
+ tr.classList.add('packet-row', 'packet-row--new');
tr.addEventListener('animationend', () => tr.classList.remove('packet-row--new'));
const time = packet.rx_time
@@ -80,7 +80,7 @@ class SimplePacketFeed {
${this._esc(details)} |
`;
- tr.addEventListener('click', () => this._toggleDetail(tr, packet));
+ tr.addEventListener('click', () => this._openDetail(tr, packet));
this._tbody.prepend(tr);
this._count++;
@@ -88,39 +88,23 @@ class SimplePacketFeed {
const countEl = document.getElementById('packet-count');
if (countEl) countEl.textContent = this._count;
- while (this._tbody.children.length > this._maxRows * 2) {
+ while (this._tbody.children.length > this._maxRows) {
this._tbody.removeChild(this._tbody.lastChild);
}
}
- _toggleDetail(tr, packet) {
- const next = tr.nextElementSibling;
- if (next && next.classList.contains('packet-detail-row')) {
- next.remove();
- if (this._onFocus) this._onFocus(null);
- return;
- }
-
- const prev = this._tbody.querySelector('.packet-detail-row');
- if (prev) prev.remove();
+ _openDetail(tr, packet) {
+ if (!window.PacketDetailModal) return;
if (this._onFocus) this._onFocus(packet.source_id);
- const detailTr = document.createElement('tr');
- detailTr.classList.add('packet-detail-row');
- const td = document.createElement('td');
- td.colSpan = 11;
-
-
- const payload = packet.decoded_payload;
- if (payload && typeof payload === 'object') {
- td.textContent = JSON.stringify(payload, null, 2);
- } else {
- td.textContent = `Source: ${packet.source_id || '--'}\nType: ${packet.packet_type || '--'}\nRSSI: ${packet.rssi || '--'} dBm\nSNR: ${packet.snr || '--'} dB`;
- }
-
- detailTr.appendChild(td);
- tr.after(detailTr);
+ window.PacketDetailModal.show(packet, {
+ formatNodeId: (id) => this._shortId(id),
+ selectedRow: tr,
+ onClose: () => {
+ if (this._onFocus) this._onFocus(null);
+ },
+ });
}
_summarize(packet) {