/* === MeshCore Analyzer — packets.js === */ 'use strict'; (function () { let packets = []; let hashIndex = new Map(); // hash → packet group for O(1) dedup // Resolve observer_id to friendly name from loaded observers list function obsName(id) { if (!id) return '—'; const o = observers.find(ob => ob.id === id); if (!o) return id; return o.iata ? `${o.name} (${o.iata})` : o.name; } let selectedId = null; let groupByHash = true; let filters = {}; { const o = localStorage.getItem('meshcore-observer-filter'); if (o) filters.observer = o; const t = localStorage.getItem('meshcore-type-filter'); if (t) filters.type = t; } let wsHandler = null; let packetsPaused = false; let pauseBuffer = []; let observers = []; let regionMap = {}; const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' }; function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; } let totalCount = 0; let expandedHashes = new Set(); let hopNameCache = {}; let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true'; let filtersBuilt = false; const PANEL_WIDTH_KEY = 'meshcore-panel-width'; function initPanelResize() { const handle = document.getElementById('pktResizeHandle'); const panel = document.getElementById('pktRight'); if (!handle || !panel) return; // Restore saved width const saved = localStorage.getItem(PANEL_WIDTH_KEY); if (saved) panel.style.width = saved + 'px'; let startX, startW; function startResize(clientX) { startX = clientX; startW = panel.offsetWidth; handle.classList.add('dragging'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; } function doResize(clientX) { const w = Math.max(280, Math.min(window.innerWidth * 0.7, startW - (clientX - startX))); panel.style.width = w + 'px'; panel.style.minWidth = w + 'px'; const left = document.getElementById('pktLeft'); if (left) { const available = left.parentElement.clientWidth - w; left.style.width = available + 'px'; } } function endResize() { handle.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; localStorage.setItem(PANEL_WIDTH_KEY, panel.offsetWidth); const left = document.getElementById('pktLeft'); if (left) left.style.width = ''; } handle.addEventListener('mousedown', (e) => { e.preventDefault(); startResize(e.clientX); function onMove(e2) { doResize(e2.clientX); } function onUp() { endResize(); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); handle.addEventListener('touchstart', (e) => { if (e.touches.length !== 1) return; e.preventDefault(); startResize(e.touches[0].clientX); function onTouchMove(e2) { if (e2.touches.length !== 1) return; e2.preventDefault(); doResize(e2.touches[0].clientX); } function onTouchEnd() { endResize(); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); } document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd); }, { passive: false }); } // Ensure HopResolver is initialized with the nodes list + observer IATA data async function ensureHopResolver() { if (!HopResolver.ready()) { try { const [nodeData, obsData, coordData] = await Promise.all([ api('/nodes?limit=2000', { ttl: 60000 }), api('/observers', { ttl: 60000 }), api('/iata-coords', { ttl: 300000 }).catch(() => ({ coords: {} })), ]); HopResolver.init(nodeData.nodes || [], { observers: obsData.observers || obsData || [], iataCoords: coordData.coords || {}, }); } catch {} } } // Resolve hop hex prefixes to node names (cached, client-side) async function resolveHops(hops) { const unknown = hops.filter(h => !(h in hopNameCache)); if (unknown.length) { await ensureHopResolver(); const resolved = HopResolver.resolve(unknown); Object.assign(hopNameCache, resolved || {}); // Cache misses as null so we don't re-query unknown.forEach(h => { if (!(h in hopNameCache)) hopNameCache[h] = null; }); } } function renderHop(h, observerId) { // Use per-packet cache key if observer context available (ambiguous hops differ by region) const cacheKey = observerId ? h + ':' + observerId : h; const entry = hopNameCache[cacheKey] || hopNameCache[h]; return HopDisplay.renderHop(h, entry, { hexMode: showHexHashes }); } function renderPath(hops, observerId) { if (!hops || !hops.length) return '—'; return hops.map(h => renderHop(h, observerId)).join(''); } let directPacketId = null; let directPacketHash = null; let initGeneration = 0; let directObsId = null; async function init(app, routeParam) { const gen = ++initGeneration; // Parse ?obs=OBSERVER_ID from routeParam if (routeParam && routeParam.includes('?')) { const qIdx = routeParam.indexOf('?'); const qs = new URLSearchParams(routeParam.substring(qIdx)); directObsId = qs.get('obs'); routeParam = routeParam.substring(0, qIdx); } // Detect route param type: "id/123" for direct packet, short hex for hash, long hex for node if (routeParam) { if (routeParam.startsWith('id/')) { directPacketId = routeParam.slice(3); } else if (routeParam.length <= 16) { filters.hash = routeParam; directPacketHash = routeParam; } else { filters.node = routeParam; } } app.innerHTML = `
Select a packet to view details
`; initPanelResize(); await loadObservers(); // Restore saved time window before first load const fTW = document.getElementById('fTimeWindow'); const savedTW = localStorage.getItem('meshcore-time-window'); if (savedTW !== null && fTW) fTW.value = savedTW; loadPackets(); // Auto-select packet detail when arriving via hash URL if (directPacketHash) { const h = directPacketHash; const obsTarget = directObsId; directPacketHash = null; directObsId = null; try { const data = await api(`/packets/${h}`); if (gen === initGeneration && data?.packet) { if (obsTarget && data.observations) { // Find the matching observation by its unique id const obs = data.observations.find(o => String(o.id) === String(obsTarget)); if (obs) { expandedHashes.add(h); const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp}; selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id); } else { selectPacket(data.packet.id, h, data); } } else { selectPacket(data.packet.id, h, data); } } } catch {} } // Event delegation for data-action buttons document.addEventListener('click', function (e) { var btn = e.target.closest('[data-action]'); if (!btn) return; if (btn.dataset.action === 'pkt-refresh') loadPackets(); else if (btn.dataset.action === 'pkt-byop') showBYOP(); else if (btn.dataset.action === 'pkt-pause') { packetsPaused = !packetsPaused; const pauseBtn = document.getElementById('pktPauseBtn'); if (pauseBtn) { pauseBtn.textContent = packetsPaused ? '▶' : '⏸'; pauseBtn.title = packetsPaused ? 'Resume live updates' : 'Pause live updates'; pauseBtn.classList.toggle('active', packetsPaused); } if (!packetsPaused && pauseBuffer.length) { const handler = wsHandler; pauseBuffer.forEach(msg => { if (handler) handler(msg); }); pauseBuffer = []; } } }); // If linked directly to a packet by ID, load its detail and filter list if (directPacketId) { const pktId = Number(directPacketId); directPacketId = null; try { const data = await api(`/packets/${pktId}`); if (gen !== initGeneration) return; if (data.packet?.hash) { filters.hash = data.packet.hash; const hashInput = document.getElementById('fHash'); if (hashInput) hashInput.value = filters.hash; await loadPackets(); } // Show detail in sidebar const panel = document.getElementById('pktRight'); if (panel) { panel.classList.remove('empty'); panel.innerHTML = '
'; const content = document.createElement('div'); panel.appendChild(content); const pkt = data.packet; try { const hops = JSON.parse(pkt.path_json || '[]'); const newHops = hops.filter(h => !(h in hopNameCache)); if (newHops.length) await resolveHops(newHops); } catch {} await renderDetail(content, data); initPanelResize(); } } catch {} } wsHandler = debouncedOnWS(function (msgs) { if (packetsPaused) { pauseBuffer.push(...msgs); const btn = document.getElementById('pktPauseBtn'); if (btn) btn.textContent = '▶ ' + pauseBuffer.length; return; } const newPkts = msgs .filter(m => m.type === 'packet' && m.data?.packet) .map(m => m.data.packet); if (!newPkts.length) return; // Check if new packets pass current filters const filtered = newPkts.filter(p => { if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; } if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; } if (filters.hash && p.hash !== filters.hash) return false; if (RegionFilter.getRegionParam()) { const selectedRegions = RegionFilter.getRegionParam().split(','); const obs = observers.find(o => o.id === p.observer_id); if (!obs || !selectedRegions.includes(obs.iata)) return false; } if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false; return true; }); if (!filtered.length) return; // Resolve any new hops, then update and re-render const newHops = new Set(); for (const p of filtered) { try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {} } (newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => { if (groupByHash) { // Update existing groups or create new ones for (const p of filtered) { const h = p.hash; const existing = hashIndex.get(h); if (existing) { existing.count = (existing.count || 1) + 1; existing.observation_count = (existing.observation_count || 1) + 1; existing.latest = p.timestamp > existing.latest ? p.timestamp : existing.latest; // Track unique observers if (p.observer_id && p.observer_id !== existing.observer_id) { existing.observer_count = (existing.observer_count || 1) + 1; } // Don't update path — header always shows first observer's path // Update decoded_json to latest if (p.decoded_json) existing.decoded_json = p.decoded_json; // Update expanded children if this group is expanded if (expandedHashes.has(h) && existing._children) { existing._children.unshift(p); sortGroupChildren(existing); } } else { // New group const newGroup = { hash: h, count: 1, observer_count: 1, latest: p.timestamp, observer_id: p.observer_id, observer_name: p.observer_name, path_json: p.path_json, payload_type: p.payload_type, raw_hex: p.raw_hex, decoded_json: p.decoded_json, }; packets.unshift(newGroup); if (h) hashIndex.set(h, newGroup); } } // Re-sort by latest DESC packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || '')); } else { // Flat mode: prepend packets = filtered.concat(packets); } totalCount += filtered.length; renderTableRows(); }); }); } function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; packets = []; hashIndex = new Map(); selectedId = null; filtersBuilt = false; delete filters.node; expandedHashes = new Set(); hopNameCache = {}; totalCount = 0; observers = []; directPacketId = null; directPacketHash = null; groupByHash = true; filters = {}; regionMap = {}; } async function loadObservers() { try { const data = await api('/observers', { ttl: CLIENT_TTL.observers }); observers = data.observers || []; } catch {} } async function loadPackets() { try { const params = new URLSearchParams(); const windowMin = Number(document.getElementById('fTimeWindow')?.value || 15); if (windowMin > 0 && !filters.hash) { const since = new Date(Date.now() - windowMin * 60000).toISOString(); params.set('since', since); } params.set('limit', '50000'); const regionParam = RegionFilter.getRegionParam(); if (regionParam) params.set('region', regionParam); if (filters.hash) params.set('hash', filters.hash); if (filters.node) params.set('node', filters.node); params.set('groupByHash', 'true'); // always fetch grouped const data = await api('/packets?' + params.toString()); packets = data.packets || []; hashIndex = new Map(); for (const p of packets) { if (p.hash) hashIndex.set(p.hash, p); } totalCount = data.total || packets.length; // When ungrouped, fetch observations for all multi-obs packets and flatten if (!groupByHash) { const multiObs = packets.filter(p => (p.observation_count || p.count || 1) > 1); await Promise.all(multiObs.map(async (p) => { try { const d = await api(`/packets/${p.hash}`); if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true})); } catch {} })); // Flatten: replace grouped packets with individual observations const flat = []; for (const p of packets) { if (p._children && p._children.length > 1) { for (const c of p._children) flat.push(c); } else { flat.push(p); } } packets = flat; totalCount = flat.length; } // Pre-resolve all path hops to node names const allHops = new Set(); for (const p of packets) { try { const path = JSON.parse(p.path_json || '[]'); path.forEach(h => allHops.add(h)); } catch {} } if (allHops.size) await resolveHops([...allHops]); // Per-observer batch resolve for ambiguous hops (context-aware disambiguation) const hopsByObserver = {}; for (const p of packets) { if (!p.observer_id) continue; try { const path = JSON.parse(p.path_json || '[]'); const ambiguous = path.filter(h => hopNameCache[h]?.ambiguous); if (ambiguous.length) { if (!hopsByObserver[p.observer_id]) hopsByObserver[p.observer_id] = new Set(); ambiguous.forEach(h => hopsByObserver[p.observer_id].add(h)); } } catch {} } // Ambiguous hops are already resolved by HopResolver client-side // No need for per-observer server API calls // Restore expanded group children if (groupByHash && expandedHashes.size > 0) { for (const hash of expandedHashes) { const group = packets.find(p => p.hash === hash); if (group) { try { const childData = await api(`/packets?hash=${hash}&limit=20`); group._children = childData.packets || []; sortGroupChildren(group); } catch {} } else { // Group no longer in results — remove from expanded expandedHashes.delete(hash); } } } renderLeft(); } catch (e) { console.error('Failed to load packets:', e); const tbody = document.getElementById('pktBody'); if (tbody) tbody.innerHTML = '
Failed to load packets. Please try again.
'; } } function renderLeft() { const el = document.getElementById('pktLeft'); if (!el) return; // Only build the filter bar + table skeleton once; subsequent calls just update rows if (filtersBuilt) { renderTableRows(); return; } filtersBuilt = true; el.innerHTML = `
RegionTimeHashSize TypeObserverPathRptDetails
`; // Init shared RegionFilter component RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true }); RegionFilter.onChange(function() { loadPackets(); }); // --- Packet Filter Language --- (function() { var pfInput = document.getElementById('packetFilterInput'); var pfError = document.getElementById('packetFilterError'); var pfCount = document.getElementById('packetFilterCount'); if (!pfInput || !window.PacketFilter) return; var pfTimer = null; pfInput.addEventListener('input', function() { clearTimeout(pfTimer); pfTimer = setTimeout(function() { var expr = pfInput.value.trim(); if (!expr) { pfInput.classList.remove('filter-error', 'filter-active'); pfError.style.display = 'none'; pfCount.style.display = 'none'; filters._packetFilter = null; renderTableRows(); return; } var compiled = PacketFilter.compile(expr); if (compiled.error) { pfInput.classList.add('filter-error'); pfInput.classList.remove('filter-active'); pfError.textContent = compiled.error; pfError.style.display = 'block'; pfCount.style.display = 'none'; filters._packetFilter = null; renderTableRows(); } else { pfInput.classList.remove('filter-error'); pfInput.classList.add('filter-active'); pfError.style.display = 'none'; filters._packetFilter = compiled.filter; renderTableRows(); } }, 300); }); })(); // --- Observer multi-select --- const obsMenu = document.getElementById('observerMenu'); const obsTrigger = document.getElementById('observerTrigger'); const selectedObservers = new Set(filters.observer ? filters.observer.split(',') : []); function buildObserverMenu() { const allChecked = selectedObservers.size === 0; let html = ``; for (const o of observers) { const checked = selectedObservers.has(String(o.id)) ? 'checked' : ''; html += ``; } obsMenu.innerHTML = html; } function updateObsTrigger() { if (selectedObservers.size === 0 || selectedObservers.size === observers.length) { obsTrigger.textContent = 'All Observers ▾'; } else if (selectedObservers.size === 1) { const id = [...selectedObservers][0]; const o = observers.find(x => String(x.id) === id); obsTrigger.textContent = (o ? (o.name || o.id) : id) + ' ▾'; } else { obsTrigger.textContent = selectedObservers.size + ' Observers ▾'; } } buildObserverMenu(); updateObsTrigger(); obsTrigger.addEventListener('click', (e) => { e.stopPropagation(); obsMenu.classList.toggle('open'); typeMenu.classList.remove('open'); }); obsMenu.addEventListener('change', (e) => { const id = e.target.dataset.obsId; if (id === '__all__') { selectedObservers.clear(); } else { if (e.target.checked) selectedObservers.add(id); else selectedObservers.delete(id); } filters.observer = selectedObservers.size > 0 ? [...selectedObservers].join(',') : undefined; if (filters.observer) localStorage.setItem('meshcore-observer-filter', filters.observer); else localStorage.removeItem('meshcore-observer-filter'); buildObserverMenu(); updateObsTrigger(); renderTableRows(); }); // --- Type multi-select --- const typeMenu = document.getElementById('typeMenu'); const typeTrigger = document.getElementById('typeTrigger'); const typeMap = {0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',7:'Anon Req',8:'Path',9:'Trace'}; const selectedTypes = new Set(filters.type ? String(filters.type).split(',') : []); function buildTypeMenu() { const allChecked = selectedTypes.size === 0; let html = ``; for (const [k, v] of Object.entries(typeMap)) { const checked = selectedTypes.has(k) ? 'checked' : ''; html += ``; } typeMenu.innerHTML = html; } function updateTypeTrigger() { const total = Object.keys(typeMap).length; if (selectedTypes.size === 0 || selectedTypes.size === total) { typeTrigger.textContent = 'All Types ▾'; } else if (selectedTypes.size === 1) { const k = [...selectedTypes][0]; typeTrigger.textContent = (typeMap[k] || k) + ' ▾'; } else { typeTrigger.textContent = selectedTypes.size + ' Types ▾'; } } buildTypeMenu(); updateTypeTrigger(); typeTrigger.addEventListener('click', (e) => { e.stopPropagation(); typeMenu.classList.toggle('open'); obsMenu.classList.remove('open'); }); typeMenu.addEventListener('change', (e) => { const id = e.target.dataset.typeId; if (id === '__all__') { selectedTypes.clear(); } else { if (e.target.checked) selectedTypes.add(id); else selectedTypes.delete(id); } filters.type = selectedTypes.size > 0 ? [...selectedTypes].join(',') : undefined; if (filters.type) localStorage.setItem('meshcore-type-filter', filters.type); else localStorage.removeItem('meshcore-type-filter'); buildTypeMenu(); updateTypeTrigger(); renderTableRows(); }); // Close multi-select menus on outside click document.addEventListener('click', (e) => { const obsWrap = document.getElementById('observerFilterWrap'); const typeWrap = document.getElementById('typeFilterWrap'); if (obsWrap && !obsWrap.contains(e.target)) { const m = obsWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); } if (typeWrap && !typeWrap.contains(e.target)) { const m = typeWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); } }); // Filter toggle button for mobile document.getElementById('filterToggleBtn').addEventListener('click', function() { const bar = document.getElementById('pktFilters'); bar.classList.toggle('filters-expanded'); this.textContent = bar.classList.contains('filters-expanded') ? 'Filters ▴' : 'Filters ▾'; }); // Filter event listeners document.getElementById('fHash').value = filters.hash || ''; document.getElementById('fHash').addEventListener('input', debounce((e) => { filters.hash = e.target.value || undefined; loadPackets(); }, 300)); // Time window dropdown — restore from localStorage and bind change const fTimeWindow = document.getElementById('fTimeWindow'); const savedWindow = localStorage.getItem('meshcore-time-window'); if (savedWindow !== null) fTimeWindow.value = savedWindow; fTimeWindow.addEventListener('change', () => { localStorage.setItem('meshcore-time-window', fTimeWindow.value); loadPackets(); }); document.getElementById('fGroup').addEventListener('click', () => { groupByHash = !groupByHash; loadPackets(); }); document.getElementById('fMyNodes').addEventListener('click', function () { filters.myNodes = !filters.myNodes; this.classList.toggle('active', filters.myNodes); loadPackets(); }); // Observation sort dropdown const obsSortSel = document.getElementById('fObsSort'); obsSortSel.value = obsSortMode; const sortHelpEl = document.getElementById('sortHelpIcon'); if (sortHelpEl) { const tip = document.createElement('span'); tip.className = 'sort-help-tip'; tip.textContent = "Sort controls how observations are ordered within packet groups and which observation appears in the header row.\n\nObserver — Groups by observer station, earliest first.\nPath \u2191 — Shortest paths first.\nPath \u2193 — Longest paths first.\nTime \u2191 — Earliest observation first.\nTime \u2193 — Most recent first."; sortHelpEl.appendChild(tip); } obsSortSel.addEventListener('change', async function () { obsSortMode = this.value; localStorage.setItem('meshcore-obs-sort', obsSortMode); // For non-observer sorts, fetch children for visible groups that don't have them yet if (obsSortMode !== SORT_OBSERVER && groupByHash) { const toFetch = packets.filter(p => p.hash && !p._children && (p.observation_count || 0) > 1); await Promise.all(toFetch.map(async (p) => { try { const data = await api(`/packets/${p.hash}`); if (data?.packet && data.observations) { p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true})); p._fetchedData = data; } } catch {} })); } // Re-sort all groups with children for (const p of packets) { if (p._children) sortGroupChildren(p); } // Resolve any new hops from updated header paths const newHops = new Set(); for (const p of packets) { try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {} } if (newHops.size) await resolveHops([...newHops]); renderTableRows(); }); // Column visibility toggle (#71) const COL_DEFS = [ { key: 'region', label: 'Region' }, { key: 'time', label: 'Time' }, { key: 'hash', label: 'Hash' }, { key: 'size', label: 'Size' }, { key: 'type', label: 'Type' }, { key: 'observer', label: 'Observer' }, { key: 'path', label: 'Path' }, { key: 'rpt', label: 'Rpt' }, { key: 'details', label: 'Details' }, ]; const isMobile = window.innerWidth <= 640; const defaultHidden = isMobile ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region']; let visibleCols; try { visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols')); } catch {} if (!visibleCols) visibleCols = COL_DEFS.map(c => c.key).filter(k => !defaultHidden.includes(k)); const colMenu = document.getElementById('colToggleMenu'); const pktTable = document.getElementById('pktTable'); function applyColVisibility() { COL_DEFS.forEach(c => { pktTable.classList.toggle('hide-col-' + c.key, !visibleCols.includes(c.key)); }); localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols)); } colMenu.innerHTML = COL_DEFS.map(c => `` ).join(''); colMenu.addEventListener('change', (e) => { const cb = e.target; const col = cb.dataset.col; if (!col) return; if (cb.checked) { if (!visibleCols.includes(col)) visibleCols.push(col); } else { visibleCols = visibleCols.filter(k => k !== col); } applyColVisibility(); }); document.getElementById('colToggleBtn').addEventListener('click', (e) => { e.stopPropagation(); colMenu.classList.toggle('open'); }); document.addEventListener('click', () => colMenu.classList.remove('open')); applyColVisibility(); document.getElementById('hexHashToggle').addEventListener('click', function () { showHexHashes = !showHexHashes; localStorage.setItem('meshcore-hex-hashes', showHexHashes); this.classList.toggle('active', showHexHashes); renderTableRows(); }); // Node name filter with autocomplete const fNode = document.getElementById('fNode'); const fNodeDrop = document.getElementById('fNodeDropdown'); fNode.value = filters.nodeName || ''; let nodeActiveIdx = -1; fNode.addEventListener('input', debounce(async (e) => { const q = e.target.value.trim(); nodeActiveIdx = -1; fNode.setAttribute('aria-activedescendant', ''); if (!q) { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); if (filters.node) { filters.node = undefined; filters.nodeName = undefined; loadPackets(); } return; } try { const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q)); const data = await resp.json(); const nodes = data.nodes || []; if (nodes.length === 0) { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); return; } fNodeDrop.innerHTML = nodes.map((n, i) => `
${escapeHtml(n.name || n.public_key.slice(0,8))} ${n.public_key.slice(0,8)}
` ).join(''); fNodeDrop.classList.remove('hidden'); fNode.setAttribute('aria-expanded', 'true'); fNodeDrop.querySelectorAll('.node-filter-option').forEach(opt => { opt.addEventListener('click', () => { selectNodeOption(opt); }); }); } catch {} }, 250)); function selectNodeOption(opt) { filters.node = opt.dataset.key; filters.nodeName = opt.dataset.name; fNode.value = opt.dataset.name; fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); fNode.setAttribute('aria-activedescendant', ''); nodeActiveIdx = -1; loadPackets(); } fNode.addEventListener('keydown', (e) => { const options = fNodeDrop.querySelectorAll('.node-filter-option'); if (!options.length || fNodeDrop.classList.contains('hidden')) return; if (e.key === 'ArrowDown') { e.preventDefault(); nodeActiveIdx = Math.min(nodeActiveIdx + 1, options.length - 1); updateNodeActive(options); } else if (e.key === 'ArrowUp') { e.preventDefault(); nodeActiveIdx = Math.max(nodeActiveIdx - 1, 0); updateNodeActive(options); } else if (e.key === 'Enter') { e.preventDefault(); if (nodeActiveIdx >= 0 && options[nodeActiveIdx]) selectNodeOption(options[nodeActiveIdx]); } else if (e.key === 'Escape') { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); nodeActiveIdx = -1; } }); function updateNodeActive(options) { options.forEach((o, i) => { o.classList.toggle('node-filter-active', i === nodeActiveIdx); o.setAttribute('aria-selected', i === nodeActiveIdx ? 'true' : 'false'); }); if (nodeActiveIdx >= 0 && options[nodeActiveIdx]) { fNode.setAttribute('aria-activedescendant', options[nodeActiveIdx].id); options[nodeActiveIdx].scrollIntoView({ block: 'nearest' }); } } fNode.addEventListener('blur', () => { setTimeout(() => { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); }, 200); }); // Delegated click/keyboard handler for table rows const pktBody = document.getElementById('pktBody'); if (pktBody) { const handler = (e) => { // Let hop links navigate naturally without selecting the row if (e.target.closest('[data-hop-link]')) return; const row = e.target.closest('tr[data-action]'); if (!row) return; if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; if (e.type === 'keydown') e.preventDefault(); const action = row.dataset.action; const value = row.dataset.value; if (action === 'select') { const hash = row.dataset.hash; if (hash) selectPacket(null, hash); else selectPacket(Number(value)); } else if (action === 'select-observation') { const parentHash = row.dataset.parentHash; const group = packets.find(p => p.hash === parentHash); const child = group?._children?.find(c => String(c.id) === String(value)); if (child) { const parentData = group._fetchedData; const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child; selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id); } } else if (action === 'select-hash') pktSelectHash(value); else if (action === 'toggle-select') { pktToggleGroup(value); pktSelectHash(value); } }; pktBody.addEventListener('click', handler); pktBody.addEventListener('keydown', handler); } // Escape to close packet detail panel document.addEventListener('keydown', function pktEsc(e) { if (e.key === 'Escape') { const panel = document.getElementById('pktRight'); if (panel && !panel.classList.contains('empty')) { panel.classList.add('empty'); panel.innerHTML = '
Select a packet to view details'; selectedId = null; renderTableRows(); } } }); renderTableRows(); makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths'); } async function renderTableRows() { const tbody = document.getElementById('pktBody'); if (!tbody) return; // Update dynamic parts of the header const countEl = document.querySelector('#pktLeft .count'); const groupBtn = document.getElementById('fGroup'); if (groupBtn) groupBtn.classList.toggle('active', groupByHash); // Filter to claimed/favorited nodes if toggle is on — use server-side multi-node lookup let displayPackets = packets; if (filters.myNodes) { const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const myKeys = myNodes.map(n => n.pubkey).filter(Boolean); const favs = getFavorites(); const allKeys = [...new Set([...myKeys, ...favs])]; if (allKeys.length > 0) { try { const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500'); displayPackets = myData.packets || []; } catch { displayPackets = []; } } else { displayPackets = []; } } // Client-side type/observer filtering if (filters.type) { const types = filters.type.split(',').map(Number); displayPackets = displayPackets.filter(p => types.includes(p.payload_type)); } if (filters.observer) { const obsIds = new Set(filters.observer.split(',')); displayPackets = displayPackets.filter(p => obsIds.has(p.observer_id)); } // Packet Filter Language const pfCount = document.getElementById('packetFilterCount'); if (filters._packetFilter) { const beforeCount = displayPackets.length; displayPackets = displayPackets.filter(filters._packetFilter); if (pfCount) { pfCount.textContent = 'Showing ' + displayPackets.length.toLocaleString() + ' of ' + beforeCount.toLocaleString() + ' packets'; pfCount.style.display = 'block'; } } else if (pfCount) { pfCount.style.display = 'none'; } if (countEl) countEl.textContent = `(${displayPackets.length})`; if (!displayPackets.length) { tbody.innerHTML = '' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + ''; return; } if (groupByHash) { let html = ''; for (const p of displayPackets) { const isExpanded = expandedHashes.has(p.hash); // When observer filter is active, use first matching child's data for header let headerObserverId = p.observer_id; let headerPathJson = p.path_json; if (filters.observer && p._children?.length) { const obsIds = new Set(filters.observer.split(',')); const match = p._children.find(c => obsIds.has(String(c.observer_id))); if (match) { headerObserverId = match.observer_id; headerPathJson = match.path_json; } } const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : ''; let groupPath = []; try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {} const groupPathStr = renderPath(groupPath, headerObserverId); const groupTypeName = payloadTypeName(p.payload_type); const groupTypeClass = payloadTypeColor(p.payload_type); const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; const isSingle = p.count <= 1; html += ` ${isSingle ? '' : (isExpanded ? '▼' : '▶')} ${groupRegion ? `${groupRegion}` : '—'} ${timeAgo(p.latest)} ${truncate(p.hash || '—', 8)} ${groupSize ? groupSize + 'B' : '—'} ${p.payload_type != null ? `${groupTypeName}` : '—'} ${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')} ${groupPathStr} ${p.observation_count > 1 ? '👁 ' + p.observation_count + '' : (isSingle ? '' : p.count)} ${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())} `; // Child rows (loaded async when expanded) if (isExpanded && p._children) { let visibleChildren = p._children; // Filter children by selected observers if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); visibleChildren = visibleChildren.filter(c => obsSet.has(String(c.observer_id))); } for (const c of visibleChildren) { const typeName = payloadTypeName(c.payload_type); const typeClass = payloadTypeColor(c.payload_type); const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0; const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : ''; let childPath = []; try { childPath = JSON.parse(c.path_json || '[]'); } catch {} const childPathStr = renderPath(childPath, c.observer_id); html += ` ${childRegion ? `${childRegion}` : '—'} ${timeAgo(c.timestamp)} ${truncate(c.hash || '', 8)} ${size}B ${typeName} ${truncate(obsName(c.observer_id), 16)} ${childPathStr} ${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())} `; } } } tbody.innerHTML = html; return; } tbody.innerHTML = displayPackets.map(p => { let decoded, pathHops = []; try { decoded = JSON.parse(p.decoded_json); } catch {} try { pathHops = JSON.parse(p.path_json || '[]'); } catch {} const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : ''; const typeName = payloadTypeName(p.payload_type); const typeClass = payloadTypeColor(p.payload_type); const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded); return ` ${region ? `${region}` : '—'} ${timeAgo(p.timestamp)} ${truncate(p.hash || String(p.id), 8)} ${size}B ${typeName} ${truncate(obsName(p.observer_id), 16)} ${pathStr} ${detail} `; }).join(''); } function getDetailPreview(decoded) { if (!decoded) return ''; // Channel messages (GRP_TXT) — show channel name and message text if (decoded.type === 'CHAN' && decoded.text) { const ch = decoded.channel ? `${escapeHtml(decoded.channel)} ` : ''; const t = decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text; return `${ch}💬 ${escapeHtml(t)}`; } // Advertisements — show node name and role if (decoded.type === 'ADVERT' && decoded.name) { const role = decoded.flags?.repeater ? '📡' : decoded.flags?.room ? '🏠' : decoded.flags?.sensor ? '🌡' : '📻'; return `${role} ${escapeHtml(decoded.name)}`; } // Direct messages if (decoded.type === 'TXT_MSG') return `✉️ ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`; // Path updates if (decoded.type === 'PATH') return `🔀 ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`; // Requests/responses (encrypted) if (decoded.type === 'REQ' || decoded.type === 'RESPONSE') return `🔒 ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`; // Anonymous requests if (decoded.type === 'ANON_REQ') return `🔒 anon → ${decoded.destHash?.slice(0,8) || '?'}`; // Companion bridge text if (decoded.text) return escapeHtml(decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text); // Bare adverts with just pubkey if (decoded.public_key) return `📡 ${decoded.public_key.slice(0, 16)}…`; return ''; } let selectedObservationId = null; async function selectPacket(id, hash, prefetchedData, obsRowId) { selectedId = id; selectedObservationId = obsRowId || null; const obsParam = selectedObservationId ? `?obs=${selectedObservationId}` : ''; if (hash) { history.replaceState(null, '', `#/packets/${hash}${obsParam}`); } else { history.replaceState(null, '', `#/packets/${id}${obsParam}`); } renderTableRows(); const isMobileNow = window.innerWidth <= 640; let panel; if (isMobileNow) { // Use mobile bottom sheet let sheet = document.getElementById('mobileDetailSheet'); if (!sheet) { sheet = document.createElement('div'); sheet.id = 'mobileDetailSheet'; sheet.className = 'mobile-detail-sheet'; sheet.innerHTML = '
'; document.body.appendChild(sheet); sheet.querySelector('#mobileSheetClose').addEventListener('click', () => { sheet.classList.remove('open'); }); sheet.querySelector('.mobile-sheet-handle').addEventListener('click', () => { sheet.classList.remove('open'); }); } panel = sheet.querySelector('.mobile-sheet-content'); panel.innerHTML = '
Loading…
'; sheet.classList.add('open'); } else { panel = document.getElementById('pktRight'); panel.classList.remove('empty'); panel.innerHTML = '
Loading…
'; initPanelResize(); } try { const data = prefetchedData || await api(hash ? `/packets/${hash}` : `/packets/${id}`); // Resolve path hops for detail view const pkt = data.packet; try { const hops = JSON.parse(pkt.path_json || '[]'); const newHops = hops.filter(h => !(h in hopNameCache)); if (newHops.length) await resolveHops(newHops); } catch {} panel.innerHTML = isMobileNow ? '' : '
'; const content = document.createElement('div'); panel.appendChild(content); await renderDetail(content, data); if (!isMobileNow) initPanelResize(); } catch (e) { panel.innerHTML = `
Error: ${e.message}
`; } } async function renderDetail(panel, data) { const pkt = data.packet; const breakdown = data.breakdown || {}; const ranges = breakdown.ranges || []; let decoded; try { decoded = JSON.parse(pkt.decoded_json); } catch { decoded = {}; } let pathHops; try { pathHops = JSON.parse(pkt.path_json || '[]'); } catch { pathHops = []; } // Resolve sender GPS — from packet directly, or from known node in DB let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null); let senderLon = decoded.lon != null ? decoded.lon : (decoded.longitude || null); if (senderLat == null) { // Try to find sender node GPS from DB const senderKey = decoded.pubKey || decoded.srcPubKey; const senderName = decoded.sender || decoded.name; try { if (senderKey) { const nd = await api(`/nodes/${senderKey}`, { ttl: 30000 }).catch(() => null); if (nd?.node?.lat && nd.node.lon) { senderLat = nd.node.lat; senderLon = nd.node.lon; } } if (senderLat == null && senderName) { const sd = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null); const match = sd?.nodes?.[0]; if (match?.lat && match.lon) { senderLat = match.lat; senderLon = match.lon; } } } catch {} } // Re-resolve hops using client-side HopResolver with sender GPS context if (pathHops.length) { try { await ensureHopResolver(); const resolved = HopResolver.resolve(pathHops); if (resolved) { for (const [k, v] of Object.entries(resolved)) { hopNameCache[k] = v; if (pkt.observer_id) hopNameCache[k + ':' + pkt.observer_id] = v; } } } catch {} } // Parse hash size from path byte const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN; const hashSize = isNaN(rawPathByte) ? null : ((rawPathByte >> 6) + 1); const size = pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0; const typeName = payloadTypeName(pkt.payload_type); const snr = pkt.snr ?? decoded.SNR ?? decoded.snr ?? null; const rssi = pkt.rssi ?? decoded.RSSI ?? decoded.rssi ?? null; const hasRawHex = !!pkt.raw_hex; // Build message preview let messageHtml = ''; if (decoded.text) { const chLabel = decoded.channel || (decoded.channel_idx != null ? `Ch ${decoded.channel_idx}` : null) || (decoded.channelHash != null ? `Ch 0x${decoded.channelHash.toString(16)}` : ''); const hopLabel = decoded.path_len != null ? `${decoded.path_len} hops` : ''; const snrLabel = snr != null ? `SNR ${snr} dB` : ''; const meta = [chLabel, hopLabel, snrLabel].filter(Boolean).join(' · '); messageHtml = `
${escapeHtml(decoded.text)}
${meta ? `
${meta}
` : ''}
`; } const observations = data.observations || []; const obsCount = data.observation_count || observations.length || 1; const uniqueObservers = new Set(observations.map(o => o.observer_id)).size; // Propagation time: spread between first and last observation let propagationHtml = '—'; if (observations.length >= 2) { const times = observations.map(o => new Date(o.timestamp).getTime()).filter(t => !isNaN(t)); if (times.length >= 2) { const first = Math.min(...times); const last = Math.max(...times); const spread = last - first; if (spread < 1000) { propagationHtml = `${spread}ms`; } else if (spread < 60000) { propagationHtml = `${(spread / 1000).toFixed(1)}s`; } else { propagationHtml = `${(spread / 60000).toFixed(1)}m`; } propagationHtml += ` (${obsCount} obs × ${uniqueObservers} observers)`; } } // Location: from ADVERT lat/lon, or from known node via pubkey/sender name let locationHtml = '—'; let locationNodeKey = null; if (decoded.lat != null && decoded.lon != null && !(decoded.lat === 0 && decoded.lon === 0)) { locationNodeKey = decoded.pubKey || decoded.srcPubKey || ''; const nodeName = decoded.name || ''; locationHtml = `${decoded.lat.toFixed(5)}, ${decoded.lon.toFixed(5)}`; if (nodeName) locationHtml = `${escapeHtml(nodeName)} — ${locationHtml}`; if (locationNodeKey) locationHtml += ` 📍map`; } else { // Try to resolve sender node location from nodes list const senderKey = decoded.pubKey || decoded.srcPubKey; const senderName = decoded.sender || decoded.name; if (senderKey || senderName) { try { const nodeData = senderKey ? await api(`/nodes/${senderKey}`, { ttl: 30000 }).catch(() => null) : null; if (nodeData && nodeData.node && nodeData.node.lat && nodeData.node.lon) { locationNodeKey = nodeData.node.public_key; locationHtml = `${nodeData.node.lat.toFixed(5)}, ${nodeData.node.lon.toFixed(5)}`; if (nodeData.node.name) locationHtml = `${escapeHtml(nodeData.node.name)} — ${locationHtml}`; locationHtml += ` 📍map`; } else if (senderName && !senderKey) { // Search by name const searchData = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null); const match = searchData && searchData.nodes && searchData.nodes[0]; if (match && match.lat && match.lon) { locationNodeKey = match.public_key; locationHtml = `${match.lat.toFixed(5)}, ${match.lon.toFixed(5)}`; locationHtml = `${escapeHtml(match.name)} — ${locationHtml}`; locationHtml += ` 📍map`; } } } catch {} } } panel.innerHTML = `
${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}
${pkt.hash || 'Packet #' + pkt.id}
${messageHtml}
Observer
${obsName(pkt.observer_id)}
Location
${locationHtml}
SNR / RSSI
${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}
Route Type
${routeTypeName(pkt.route_type)}
Payload Type
${typeName}
${hashSize ? `
Hash Size
${hashSize} byte${hashSize !== 1 ? 's' : ''}
` : ''}
Timestamp
${pkt.timestamp}
Propagation
${propagationHtml}
Path
${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}
${pathHops.length ? `` : ''} ${pkt.hash ? `🔍 Trace` : ''}
${hasRawHex ? `
${buildHexLegend(ranges)}
${createColoredHexDump(pkt.raw_hex, ranges)}
` : ''} ${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)} `; // Wire up copy link button const copyLinkBtn = panel.querySelector('.copy-link-btn'); if (copyLinkBtn) { copyLinkBtn.addEventListener('click', () => { const pktHash = copyLinkBtn.dataset.packetHash; const obsParam = selectedObservationId ? `?obs=${selectedObservationId}` : ''; const url = pktHash ? `${location.origin}/#/packets/${pktHash}${obsParam}` : `${location.origin}/#/packets/${copyLinkBtn.dataset.packetId}${obsParam}`; navigator.clipboard.writeText(url).then(() => { copyLinkBtn.textContent = '✅ Copied!'; setTimeout(() => { copyLinkBtn.textContent = '🔗 Copy Link'; }, 1500); }).catch(() => { prompt('Copy this link:', url); }); }); } // Wire up replay button const replayBtn = panel.querySelector('.replay-live-btn'); if (replayBtn) { replayBtn.addEventListener('click', () => { // Build replay packets for ALL observations of this transmission const obs = data.observations || []; const replayPackets = []; if (obs.length > 1) { for (const o of obs) { let oPath; try { oPath = JSON.parse(o.path_json || '[]'); } catch { oPath = pathHops; } let oDec; try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; } replayPackets.push({ id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex, _ts: new Date(o.timestamp).getTime(), decoded: { header: { payloadTypeName: typeName }, payload: oDec, path: { hops: oPath } }, snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id) }); } } else { replayPackets.push({ id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex, _ts: new Date(pkt.timestamp).getTime(), decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } }, snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id) }); } sessionStorage.setItem('replay-packet', JSON.stringify(replayPackets)); window.location.hash = '#/live'; }); } // Wire up view route on map button const routeBtn = document.getElementById('viewRouteBtn'); if (routeBtn && pathHops.length) { routeBtn.addEventListener('click', async () => { try { // Anchor disambiguation from sender's location if known (e.g. ADVERT lat/lon) const senderLat = decoded.lat || decoded.latitude; const senderLon = decoded.lon || decoded.longitude; // Resolve observer position for backward-pass anchor let obsLat = null, obsLon = null; const obsId = obsName(pkt.observer_id); if (obsId && HopResolver.ready()) { // Try to find observer in nodes list by name — best effort } await ensureHopResolver(); const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) }; // Pass full pubkeys (client-disambiguated) to map, falling back to short prefix const resolvedKeys = pathHops.map(h => { const r = data.resolved?.[h]; return r?.pubkey || h; }); // Build origin info for the sender node const origin = {}; if (decoded.pubKey) origin.pubkey = decoded.pubKey; else if (decoded.srcHash) origin.pubkey = decoded.srcHash; if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name; if (senderLat != null && senderLon != null) { origin.lat = senderLat; origin.lon = senderLon; } sessionStorage.setItem('map-route-hops', JSON.stringify({ origin: origin, hops: resolvedKeys })); window.location.hash = '#/map?route=1'; } catch { window.location.hash = '#/map'; } }); } } function buildDecodedTable(decoded) { let rows = ''; for (const [k, v] of Object.entries(decoded)) { if (v === null || v === undefined) continue; rows += `${escapeHtml(k)}${escapeHtml(String(v))}`; } return rows ? `${rows}
` : ''; } function buildFieldTable(pkt, decoded, pathHops, ranges) { const buf = pkt.raw_hex || ''; const size = Math.floor(buf.length / 2); let rows = ''; // Header section rows += sectionRow('Header'); rows += fieldRow(0, 'Header Byte', '0x' + (buf.slice(0, 2) || '??'), `Route: ${routeTypeName(pkt.route_type)}, Payload: ${payloadTypeName(pkt.payload_type)}`); const pathByte0 = parseInt(buf.slice(2, 4), 16); const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1); const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F); rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`); // Transport codes let off = 2; if (pkt.route_type === 0 || pkt.route_type === 3) { rows += sectionRow('Transport Codes'); rows += fieldRow(off, 'Next Hop', buf.slice(off * 2, (off + 2) * 2), ''); rows += fieldRow(off + 2, 'Last Hop', buf.slice((off + 2) * 2, (off + 4) * 2), ''); off += 4; } // Path if (pathHops.length > 0) { rows += sectionRow('Path (' + pathHops.length + ' hops)'); const pathByte = parseInt(buf.slice(2, 4), 16); const hashSize = (pathByte >> 6) + 1; for (let i = 0; i < pathHops.length; i++) { const hopHtml = HopDisplay.renderHop(pathHops[i], hopNameCache[pathHops[i]]); const label = `Hop ${i} — ${hopHtml}`; rows += fieldRow(off + i * hashSize, label, pathHops[i], ''); } off += hashSize * pathHops.length; } // Payload rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type)); if (decoded.type === 'ADVERT') { rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1)); rows += fieldRow(off, 'Public Key (32B)', truncate(decoded.pubKey || '', 24), ''); rows += fieldRow(off + 32, 'Timestamp (4B)', decoded.timestampISO || '', 'Unix: ' + (decoded.timestamp || '')); rows += fieldRow(off + 36, 'Signature (64B)', truncate(decoded.signature || '', 24), ''); if (decoded.flags) { const _typeLabels = {1:'Companion',2:'Repeater',3:'Room Server',4:'Sensor'}; const _typeName = _typeLabels[decoded.flags.type] || ('Unknown(' + decoded.flags.type + ')'); const _boolFlags = [decoded.flags.hasLocation && 'location', decoded.flags.hasName && 'name'].filter(Boolean); const _flagDesc = _typeName + (_boolFlags.length ? ' + ' + _boolFlags.join(', ') : ''); rows += fieldRow(off + 100, 'App Flags', '0x' + (decoded.flags.raw?.toString(16).padStart(2,'0') || '??'), _flagDesc); let fOff = off + 101; if (decoded.flags.hasLocation) { rows += fieldRow(fOff, 'Latitude', decoded.lat?.toFixed(6) || '', ''); rows += fieldRow(fOff + 4, 'Longitude', decoded.lon?.toFixed(6) || '', ''); fOff += 8; } if (decoded.flags.hasName) { rows += fieldRow(fOff, 'Node Name', decoded.pubKey ? `${escapeHtml(decoded.name || '')}` : escapeHtml(decoded.name || ''), ''); } } } else if (decoded.type === 'GRP_TXT') { rows += fieldRow(off, 'Channel Hash', decoded.channelHash, ''); rows += fieldRow(off + 1, 'MAC (2B)', decoded.mac || '', ''); rows += fieldRow(off + 3, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), ''); } else if (decoded.type === 'CHAN') { rows += fieldRow(off, 'Channel', decoded.channel || `0x${(decoded.channelHash || 0).toString(16)}`, ''); rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', ''); if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, ''); } else if (decoded.type === 'ACK') { rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', ''); rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', ''); rows += fieldRow(off + 12, 'Extra (6B)', decoded.extraHash || '', ''); } else if (decoded.destHash !== undefined) { rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', ''); rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', ''); rows += fieldRow(off + 12, 'MAC (4B)', decoded.mac || '', ''); rows += fieldRow(off + 16, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), ''); } else { rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), ''); } return `${rows}
OffsetFieldValueDescription
`; } function sectionRow(label) { return `${label}`; } function fieldRow(offset, name, value, desc) { return `${offset}${name}${value}${desc || ''}`; } // BYOP modal — decode only, no DB injection function showBYOP() { const triggerBtn = document.querySelector('[data-action="pkt-byop"]'); const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); const modal = overlay.querySelector('.byop-modal'); const close = () => { overlay.remove(); if (triggerBtn) triggerBtn.focus(); }; overlay.querySelector('.byop-x').onclick = close; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); // Focus trap function getFocusable() { return modal.querySelectorAll('textarea, button, input, [tabindex]:not([tabindex="-1"])'); } overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (e.key === 'Tab') { const focusable = getFocusable(); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } }); const textarea = overlay.querySelector('#byopHex'); textarea.focus(); textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); doDecode(); } }); overlay.querySelector('#byopDecode').onclick = doDecode; async function doDecode() { const hex = textarea.value.trim().replace(/[\s\n]/g, ''); const result = document.getElementById('byopResult'); if (!hex) { result.innerHTML = '

Enter hex data

'; return; } if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = ''; return; } result.innerHTML = '

Decoding...

'; try { const res = await fetch('/api/decode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hex }) }); const data = await res.json(); if (data.error) throw new Error(data.error); result.innerHTML = renderDecodedPacket(data.decoded, hex); } catch (e) { result.innerHTML = ''; } } } function renderDecodedPacket(d, hex) { const h = d.header || {}; const p = d.payload || {}; const path = d.path || {}; const size = hex ? Math.floor(hex.length / 2) : 0; let html = '
'; // Header section html += '
' + '
Header
' + '
' + kv('Route Type', routeTypeName(h.routeType)) + kv('Payload Type', payloadTypeName(h.payloadType)) + kv('Version', h.payloadVersion) + kv('Size', size + ' bytes') + '
'; // Path section if (path.hops && path.hops.length) { html += '
' + '
Path (' + path.hops.length + ' hops)
' + '
' + path.hops.map(function(hop) { return '' + hop + ''; }).join('') + '
' + '
'; } // Payload section html += '
' + '
Payload — ' + payloadTypeName(h.payloadType) + '
' + '
'; for (const [k, v] of Object.entries(p)) { if (v === null || v === undefined) continue; if (typeof v === 'object') { html += kv(k, '
' + JSON.stringify(v, null, 2) + '
'); } else { html += kv(k, String(v)); } } html += '
'; // Raw hex html += '
' + '
Raw Hex
' + '
' + hex.toUpperCase().match(/.{1,2}/g).join(' ') + '
' + '
'; html += '
'; return html; } function kv(key, val) { return '
' + key + '' + val + '
'; } // Load regions from config API (async () => { try { regionMap = await api('/config/regions', { ttl: 3600 }); } catch {} })(); // Observation sort modes const SORT_OBSERVER = 'observer'; const SORT_PATH_ASC = 'path-asc'; const SORT_PATH_DESC = 'path-desc'; const SORT_CHRONO_ASC = 'chrono-asc'; const SORT_CHRONO_DESC = 'chrono-desc'; let obsSortMode = localStorage.getItem('meshcore-obs-sort') || SORT_OBSERVER; function getPathHopCount(c) { try { return JSON.parse(c.path_json || '[]').length; } catch { return 0; } } function sortGroupChildren(group) { if (!group || !group._children || !group._children.length) return; const mode = obsSortMode; if (mode === SORT_CHRONO_ASC || mode === SORT_CHRONO_DESC) { const dir = mode === SORT_CHRONO_ASC ? 1 : -1; group._children.sort((a, b) => { const tA = a.timestamp || '', tB = b.timestamp || ''; return tA < tB ? -dir : tA > tB ? dir : 0; }); } else if (mode === SORT_PATH_ASC || mode === SORT_PATH_DESC) { const dir = mode === SORT_PATH_ASC ? 1 : -1; group._children.sort((a, b) => { const lenA = getPathHopCount(a), lenB = getPathHopCount(b); if (lenA !== lenB) return (lenA - lenB) * dir; const oA = (a.observer_name || '').toLowerCase(), oB = (b.observer_name || '').toLowerCase(); return oA < oB ? -1 : oA > oB ? 1 : 0; }); } else { // Default: group by observer, earliest-observer first, then ascending time within each const earliest = {}; for (const c of group._children) { const obs = c.observer_name || c.observer || ''; const t = c.timestamp || c.rx_at || c.created_at || ''; if (!earliest[obs] || t < earliest[obs]) earliest[obs] = t; } group._children.sort((a, b) => { const oA = a.observer_name || a.observer || '', oB = b.observer_name || b.observer || ''; const eA = earliest[oA] || '', eB = earliest[oB] || ''; if (eA !== eB) return eA < eB ? -1 : 1; if (oA !== oB) return oA < oB ? -1 : 1; const tA = a.timestamp || a.rx_at || '', tB = b.timestamp || b.rx_at || ''; return tA < tB ? -1 : tA > tB ? 1 : 0; }); } // Update header row to match first sorted child const first = group._children[0]; if (first) { group.observer_id = first.observer_id; group.observer_name = first.observer_name; group.snr = first.snr; group.rssi = first.rssi; group.path_json = first.path_json; group.direction = first.direction; } } // Global handlers async function pktToggleGroup(hash) { if (expandedHashes.has(hash)) { expandedHashes.delete(hash); renderTableRows(); return; } // Single fetch — gets packet + observations + path + breakdown try { const data = await api(`/packets/${hash}`); const pkt = data.packet; if (!pkt) return; const group = packets.find(p => p.hash === hash); if (group && data.observations) { group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true})); group._fetchedData = data; // Sort children based on current sort mode sortGroupChildren(group); } // Resolve any new hops from children const childHops = new Set(); for (const c of (group?._children || [])) { try { JSON.parse(c.path_json || '[]').forEach(h => childHops.add(h)); } catch {} } const newHops = [...childHops].filter(h => !(h in hopNameCache)); if (newHops.length) await resolveHops(newHops); expandedHashes.add(hash); renderTableRows(); // Also open detail panel — no extra fetch needed selectPacket(pkt.id, hash, data); } catch {} } async function pktSelectHash(hash) { // When grouped, select packet — reuse cached detail endpoint try { const data = await api(`/packets/${hash}`); if (data?.packet) selectPacket(data.packet.id, hash, data); } catch {} } let _themeRefreshHandler = null; registerPage('packets', { init: function(app, routeParam) { _themeRefreshHandler = () => { if (typeof renderTableRows === 'function') renderTableRows(); }; window.addEventListener('theme-refresh', _themeRefreshHandler); return init(app, routeParam); }, destroy: function() { if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } return destroy(); } }); // Standalone packet detail page: #/packet/123 or #/packet/HASH registerPage('packet-detail', { init: async (app, routeParam) => { const param = routeParam; app.innerHTML = `
Loading packet…
`; try { await loadObservers(); const data = await api(`/packets/${param}`); if (!data?.packet) { app.innerHTML = `

Packet not found

Packet ${param} doesn't exist.

← Back to packets
`; return; } const hops = []; try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {} const newHops = hops.filter(h => !(h in hopNameCache)); if (newHops.length) await resolveHops(newHops); const container = document.createElement('div'); container.style.cssText = 'max-width:800px;margin:0 auto;padding:20px'; container.innerHTML = `
← Back to packets
`; const detail = document.createElement('div'); container.appendChild(detail); await renderDetail(detail, data); app.innerHTML = ''; app.appendChild(container); } catch (e) { app.innerHTML = `

Error

${e.message}

← Back to packets
`; } }, destroy: () => {} }); })();