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 = ` +
+
+

Packet detail

+
${this._esc(timeLabel)} · ${this._esc(packet.packet_type || 'unknown')}
+
+ +
+
+ `; + + 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) {