/* === CoreScope — packets.js === */ 'use strict'; /* === #1056: TableResponsive — fluid columns + "+N hidden" pill ============ * Tiny helper, defined once, used by packets/nodes/observers tables. * * Usage: TableResponsive.apply(tableEl) * * Each may carry a `data-priority` attribute (1=keep always, higher * numbers = drop first as viewport narrows). Default priority is 1. * * apply() measures the container width and progressively hides the highest- * priority columns (and matching s) until the table's natural scrollWidth * fits, then renders a "+N hidden" pill in the last visible . Click the * pill to reveal all hidden columns until the next layout pass. * * Re-runs on window resize (debounced) and is idempotent — safe to call after * every render. ResizeObserver on the wrapping element also triggers re-fit. */ (function () { if (window.TableResponsive) return; const REVEAL_FLAG = '__tr_reveal'; const PILL_CLASS = 'col-hidden-pill'; const HIDDEN_CLASS = 'col-hidden'; function thsOf(table) { return Array.from(table.querySelectorAll('thead > tr > th')); } function clearHidden(table) { table.querySelectorAll('.' + HIDDEN_CLASS).forEach(el => el.classList.remove(HIDDEN_CLASS)); const pill = table.querySelector('.' + PILL_CLASS); if (pill) pill.remove(); } function colIndexCells(table, idx) { // Return the at column index `idx` for every body row. const out = []; const rows = table.querySelectorAll('tbody > tr'); rows.forEach(r => { // colSpan-aware mapping: walk cells, accumulate colspans. let i = 0; for (const cell of r.children) { const span = cell.colSpan || 1; if (i <= idx && idx < i + span) { out.push(cell); break; } i += span; } }); return out; } function apply(table) { if (!table || !table.isConnected) return; if (table[REVEAL_FLAG]) { // user explicitly requested reveal — clear hidden state and skip clearHidden(table); return; } clearHidden(table); const ths = thsOf(table); if (ths.length === 0) return; // Viewport-breakpoint hiding (per issue #1056 acceptance criteria): // data-priority on each : // 1 → always visible // 2 → hide when viewport ≤ 1280 // 3 → hide when viewport ≤ 1024 (per AC #1 wording) // 4 → hide when viewport ≤ 900 // 5 → hide when viewport ≤ 768 // Higher priority numbers drop FIRST (least important). // Drop direction: a column is hidden if its breakpoint ≥ current viewport. const BP = { 2: 1280, 3: 1024, 4: 900, 5: 768 }; const vw = window.innerWidth || document.documentElement.clientWidth; const candidates = ths .map((th, i) => ({ th, i, prio: parseInt(th.getAttribute('data-priority') || '1', 10) })) .filter(c => c.prio > 1 && BP[c.prio] !== undefined && vw <= BP[c.prio]) // hide highest priority numbers first (drop-first), then right-to-left ties .sort((a, b) => b.prio - a.prio || b.i - a.i); let hidden = 0; for (const c of candidates) { c.th.classList.add(HIDDEN_CLASS); colIndexCells(table, c.i).forEach(td => td.classList.add(HIDDEN_CLASS)); hidden++; } if (hidden > 0) { const visible = ths.filter(th => !th.classList.contains(HIDDEN_CLASS)); const host = visible[visible.length - 1] || ths[0]; const pill = document.createElement('button'); pill.type = 'button'; pill.className = PILL_CLASS; pill.textContent = '+' + hidden + ' hidden'; pill.title = 'Click to reveal hidden columns'; pill.setAttribute('aria-label', hidden + ' columns hidden — click to reveal'); pill.addEventListener('click', function (ev) { ev.stopPropagation(); ev.preventDefault(); table[REVEAL_FLAG] = true; clearHidden(table); // Add a small "hide again" affordance after reveal so the user isn't stuck. const rehide = document.createElement('button'); rehide.type = 'button'; rehide.className = PILL_CLASS + ' col-rehide-pill'; rehide.textContent = 'hide'; rehide.title = 'Re-hide collapsed columns'; rehide.setAttribute('aria-label', 'Re-hide previously collapsed columns'); rehide.addEventListener('click', function (ev2) { ev2.stopPropagation(); ev2.preventDefault(); table[REVEAL_FLAG] = false; apply(table); }); rehide.addEventListener('keydown', function (ev2) { // Prevent Enter/Space from bubbling up to TableSort handler on the . if (ev2.key === 'Enter' || ev2.key === ' ') ev2.stopPropagation(); }); host.appendChild(rehide); }); // MAJOR-3: prevent Enter/Space keydown on the pill from bubbling to the // 's TableSort keydown handler (which would also trigger a sort). pill.addEventListener('keydown', function (ev) { if (ev.key === 'Enter' || ev.key === ' ') ev.stopPropagation(); }); host.appendChild(pill); } } // Track tables we've wired up so resize triggers re-apply. const wired = new Set(); // Track last-seen wrap width per table so we only treat ACTUAL container // resizes as a reason to drop the user's reveal state. Hiding/showing // columns and removing the pill mutate layout and re-trigger ResizeObserver, // which would otherwise immediately stomp on the reveal the user just asked for. const lastWrapW = new WeakMap(); function register(table) { if (!table || wired.has(table)) { apply(table); return; } wired.add(table); if (typeof ResizeObserver !== 'undefined') { const wrap = table.closest('.table-fluid-wrap, .obs-table-scroll, .table-scroll-wrap') || table.parentElement; if (wrap) { lastWrapW.set(table, wrap.clientWidth || 0); const ro = new ResizeObserver(() => { const prev = lastWrapW.get(table) || 0; const cur = wrap.clientWidth || 0; // Ignore self-induced layout reflows from apply()/clearHidden() — // they don't change the wrap width. Only real viewport/container // changes (>2px) clear the reveal flag. if (Math.abs(cur - prev) <= 2) return; lastWrapW.set(table, cur); table[REVEAL_FLAG] = false; apply(table); }); ro.observe(wrap); } } apply(table); } let _winTimer = null; window.addEventListener('resize', function () { clearTimeout(_winTimer); _winTimer = setTimeout(() => { wired.forEach(t => { if (!t.isConnected) { wired.delete(t); return; } t[REVEAL_FLAG] = false; apply(t); }); }, 120); }); window.TableResponsive = { apply, register }; })(); (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 = observerMap.get(id); if (!o) return id; return o.iata ? `${o.name} (${o.iata})` : o.name; } let selectedId = null; function _isColorByHash() { return localStorage.getItem('meshcore-color-packets-by-hash') !== 'false'; } function _currentTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } function _hashStripeStyle(hash) { return _isColorByHash() && hash && window.HashColor ? 'border-left:4px solid ' + HashColor.hashToHsl(hash, _currentTheme()) + ';' : ''; } 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 observerMap = new Map(); // id → observer for O(1) lookups (#383) let regionMap = {}; const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 6:'Group Data', 7:'Anon Req', 8:'Path', 9:'Trace', 10:'Multipart', 11:'Control', 15:'Raw Custom' }; function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; } const isMobile = window.innerWidth <= 1024; const PACKET_LIMIT = isMobile ? 1000 : 50000; let savedTimeWindowMin = Number(localStorage.getItem('meshcore-time-window')); if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15; if (isMobile && savedTimeWindowMin > 180) savedTimeWindowMin = 15; let totalCount = 0; let expandedHashes = new Set(); let hopNameCache = {}; let _tableSortInstance = null; let _packetSortColumn = null; let _packetSortDirection = 'desc'; let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true'; var _pendingUrlRegion = null; var DEFAULT_TIME_WINDOW = 15; function buildPacketsQuery(timeWindowMin, regionParam) { var parts = []; if (timeWindowMin && timeWindowMin !== DEFAULT_TIME_WINDOW) parts.push('timeWindow=' + timeWindowMin); if (regionParam) parts.push('region=' + encodeURIComponent(regionParam)); if (filters.hash) parts.push('hash=' + encodeURIComponent(filters.hash)); if (filters.node) parts.push('node=' + encodeURIComponent(filters.node)); if (filters.observer) parts.push('observer=' + encodeURIComponent(filters.observer)); if (filters.channel) parts.push('channel=' + encodeURIComponent(filters.channel)); if (filters._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr)); // Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted. if (_packetSortColumn) { var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc'; if (!sortDefault && window.URLState) { var sortToken = URLState.serializeSort(_packetSortColumn, _packetSortDirection); if (sortToken) parts.push('sort=' + encodeURIComponent(sortToken)); } } return parts.length ? '?' + parts.join('&') : ''; } window.buildPacketsQuery = buildPacketsQuery; function updatePacketsUrl() { // Preserve any subpath after /packets (e.g. #/packets/). var cur = String(location.hash || ''); var subpath = ''; var m = cur.match(/^#\/packets(\/[^?]*)?/); if (m && m[1]) subpath = m[1]; history.replaceState(null, '', '#/packets' + subpath + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam())); // Update clear-filters button visibility var cb = document.getElementById('clearFiltersBtn'); if (cb) { var active = !!(filters.hash || filters.node || filters.observer || filters.channel || filters.type || filters._filterExpr || filters.myNodes) || !!RegionFilter.getRegionParam() || savedTimeWindowMin !== DEFAULT_TIME_WINDOW; cb.style.display = active ? '' : 'none'; } } let filtersBuilt = false; let _renderTimer = null; function scheduleRender() { clearTimeout(_renderTimer); _renderTimer = setTimeout(() => renderTableRows(), 200); } // Coalesce WS-triggered renders into one per animation frame (#396). // Multiple WS batches arriving within the same frame only trigger a single // renderTableRows() call on the next rAF, preventing rapid full rebuilds. function scheduleWSRender() { _wsRenderDirty = true; if (_wsRafId) return; // already scheduled _wsRafId = requestAnimationFrame(function () { _wsRafId = null; if (_wsRenderDirty) { _wsRenderDirty = false; renderTableRows(); } }); } const PANEL_WIDTH_KEY = 'meshcore-panel-width'; const PANEL_CLOSE_HTML = ''; // getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file) const getParsedPath = window.getParsedPath; const getParsedDecoded = window.getParsedDecoded; // --- Virtual scroll state --- let VSCROLL_ROW_HEIGHT = 36; // measured dynamically on first render; fallback 36px let _vscrollRowHeightMeasured = false; let _vscrollTheadHeight = 40; // measured dynamically on first render; fallback 40px const VSCROLL_BUFFER = 30; // extra rows above/below viewport let _displayPackets = []; // filtered packets for current view let _displayGrouped = false; // whether _displayPackets is in grouped mode let _rowCounts = []; // per-entry DOM row counts (1 for flat, 1+children for expanded groups) let _rowCountsDirty = false; // set when _rowCounts may be stale (e.g. WS added children) (#410) let _cumulativeOffsetsCache = null; // cached cumulative offsets, invalidated on _rowCounts change let _lastVisibleStart = -1; // last rendered start index (for dirty checking) let _lastVisibleEnd = -1; // last rendered end index (for dirty checking) let _vsScrollHandler = null; // scroll listener reference let _wsRenderTimer = null; // debounce timer for WS-triggered renders let _wsRafId = null; // rAF id for coalescing WS-triggered renders (#396) let _wsRenderDirty = false; // dirty flag for rAF render coalescing (#396) let _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427) // Pure function: calculate visible entry range from scroll state. // Extracted for testability (#405, #409). function _calcVisibleRange(offsets, entryCount, scrollTop, viewportHeight, rowHeight, theadHeight, buffer) { const adjustedScrollTop = Math.max(0, scrollTop - theadHeight); const firstDomRow = Math.floor(adjustedScrollTop / rowHeight); const visibleDomCount = Math.ceil(viewportHeight / rowHeight); // Binary search for first entry whose cumulative offset covers firstDomRow let lo = 0, hi = entryCount; while (lo < hi) { const mid = (lo + hi) >>> 1; if (offsets[mid + 1] <= firstDomRow) lo = mid + 1; else hi = mid; } const firstEntry = lo; // Binary search for last visible entry const lastDomRow = firstDomRow + visibleDomCount; lo = firstEntry; hi = entryCount; while (lo < hi) { const mid = (lo + hi) >>> 1; if (offsets[mid + 1] <= lastDomRow) lo = mid + 1; else hi = mid; } const lastEntry = Math.min(lo + 1, entryCount); const startIdx = Math.max(0, firstEntry - buffer); const endIdx = Math.min(entryCount, lastEntry + buffer); return { startIdx, endIdx, firstEntry, lastEntry }; } function closeDetailPanel() { var panel = document.getElementById('pktRight'); if (panel) { panel.classList.add('empty'); panel.innerHTML = '
' + PANEL_CLOSE_HTML + 'Select a packet to view details'; var layout = panel.closest('.split-layout'); if (layout) layout.classList.add('detail-collapsed'); selectedId = null; renderTableRows(); } } 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; }); } } /** * Pre-populate hopNameCache from server-side resolved_path on packets. * Packets with resolved_path skip client-side HopResolver entirely. * Must call ensureHopResolver() first so nodesList is available for name lookup. */ async function cacheResolvedPaths(packets) { if (!packets || !packets.length) return; let needsInit = false; for (const p of packets) { const rp = getResolvedPath(p); if (rp) { needsInit = true; break; } } if (!needsInit) return; await ensureHopResolver(); for (const p of packets) { const rp = getResolvedPath(p); if (!rp) continue; const hops = getParsedPath(p); const resolved = HopResolver.resolveFromServer(hops, rp); Object.assign(hopNameCache, resolved); } } 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 _docActionHandler = null; let _docMenuCloseHandler = null; let _docColMenuCloseHandler = null; let directObsId = null; function removeAllByopOverlays() { document.querySelectorAll('.byop-overlay').forEach(function (el) { el.remove(); }); } function bindDocumentHandler(kind, eventName, handler) { const prev = kind === 'action' ? _docActionHandler : kind === 'menu' ? _docMenuCloseHandler : _docColMenuCloseHandler; if (prev) document.removeEventListener(eventName, prev); document.addEventListener(eventName, handler); if (kind === 'action') _docActionHandler = handler; else if (kind === 'menu') _docMenuCloseHandler = handler; else _docColMenuCloseHandler = handler; } function renderTimestampCell(isoString) { if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') { return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—'); } const f = formatTimestampWithTooltip(isoString, getTimestampMode()); const warn = f.isFuture ? ' ⚠️' : ''; return `${escapeHtml(f.text)}${warn}`; } 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; } } // Read URL params (router strips query from routeParam; read from location.hash) var _initUrlParams = getHashParams(); var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { savedTimeWindowMin = _urlTimeWindow; localStorage.setItem('meshcore-time-window', String(_urlTimeWindow)); } var _urlRegion = _initUrlParams.get('region'); if (_urlRegion) _pendingUrlRegion = _urlRegion; var _urlHash = _initUrlParams.get('hash'); if (_urlHash) filters.hash = _urlHash; var _urlNode = _initUrlParams.get('node'); if (_urlNode) { filters.node = _urlNode; filters.nodeName = _urlNode.slice(0, 8); } var _urlObserver = _initUrlParams.get('observer'); if (_urlObserver) filters.observer = _urlObserver; var _urlChannel = _initUrlParams.get('channel'); if (_urlChannel) filters.channel = _urlChannel; var _urlFilterExpr = _initUrlParams.get('filter'); if (_urlFilterExpr) filters._filterExpr = _urlFilterExpr; // #749 — restore sort state from URL (overrides localStorage). var _urlSort = _initUrlParams.get('sort'); if (_urlSort && window.URLState) { var _parsed = URLState.parseSort(_urlSort); if (_parsed) { _packetSortColumn = _parsed.column; _packetSortDirection = _parsed.direction; // Persist so TableSort init picks it up. try { localStorage.setItem('meshcore-packets-sort', JSON.stringify({ column: _parsed.column, direction: _parsed.direction })); } catch {} } } app.innerHTML = `
${PANEL_CLOSE_HTML} Select a packet to view details
`; initPanelResize(); document.getElementById('pktRight').addEventListener('click', function(e) { if (e.target.closest('.panel-close-btn')) closeDetailPanel(); }); await loadObservers(); 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, resolved_path: obs.resolved_path, direction: obs.direction, timestamp: obs.timestamp, first_seen: obs.timestamp}; clearParsedCache(obsPacket); selectPacket(obs.id, h, {packet: obsPacket, 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 bindDocumentHandler('action', '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 = '
' + PANEL_CLOSE_HTML; const content = document.createElement('div'); panel.appendChild(content); const pkt = data.packet; try { const hops = getParsedPath(pkt); 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); if (pauseBuffer.length > 2000) pauseBuffer = pauseBuffer.slice(-2000); 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 => { // When user pinned a hash, accept ONLY that exact packet — bypass all // other filters (window/region/type/observer/node). if (filters.hash) return p.hash === filters.hash; // Respect time window filter — drop packets outside the selected window const windowMin = savedTimeWindowMin; if (windowMin > 0) { const cutoff = new Date(Date.now() - windowMin * 60000).toISOString(); const pktTime = p.latest || p.timestamp || p.first_seen; if (pktTime && pktTime < cutoff) return false; } 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) && !(p._children && p._children.some(c => obsSet.has(String(c.observer_id))))) return false; } if (RegionFilter.getRegionParam()) { const selectedRegions = RegionFilter.getRegionParam().split(','); const obs = observerMap.get(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 // Pre-populate from server-side resolved_path, then fall back for remaining const newHops = new Set(); for (const p of filtered) { const rp = getResolvedPath(p); const hops = getParsedPath(p); if (rp && rp.length === hops.length && window.HopResolver && HopResolver.ready()) { const resolved = HopResolver.resolveFromServer(hops, rp); Object.assign(hopNameCache, resolved); } try { hops.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(clearParsedCache({...p, _isObservation: true})); if (existing._children.length > 200) existing._children.length = 200; sortGroupChildren(existing); // Invalidate row counts — child count changed, so virtual scroll // heights are stale until next renderTableRows() (#410) _invalidateRowCounts(); } } 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 active sort column (or latest DESC as default), then evict oldest beyond the limit if (_packetSortColumn) { sortPacketsArray(); } else { packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || '')); } if (packets.length > PACKET_LIMIT) { const evicted = packets.splice(PACKET_LIMIT); for (const p of evicted) { if (p.hash) hashIndex.delete(p.hash); } } } else { // Flat mode: prepend, then evict oldest beyond the limit packets = filtered.concat(packets); if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT; } totalCount += filtered.length; // Coalesce WS-triggered renders via rAF (#396) scheduleWSRender(); }); }); } function destroy() { clearTimeout(_renderTimer); if (wsHandler) offWS(wsHandler); wsHandler = null; if (_tableSortInstance) { _tableSortInstance.destroy(); _tableSortInstance = null; } detachVScrollListener(); clearTimeout(_wsRenderTimer); if (_wsRafId) { cancelAnimationFrame(_wsRafId); _wsRafId = null; } _wsRenderDirty = false; _displayPackets = []; _rowCounts = []; _rowCountsDirty = false; _cumulativeOffsetsCache = null; _observerFilterSet = null; _lastVisibleStart = -1; _lastVisibleEnd = -1; if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; } if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; } if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; } removeAllByopOverlays(); packets = []; hashIndex = new Map(); selectedId = null; filtersBuilt = false; delete filters.node; expandedHashes = new Set(); hopNameCache = {}; totalCount = 0; observers = []; observerMap = new Map(); directPacketId = null; directPacketHash = null; groupByHash = true; filters = {}; regionMap = {}; } async function loadObservers() { try { const data = await api('/observers', { ttl: CLIENT_TTL.observers }); observers = data.observers || []; observerMap = new Map(observers.map(o => [o.id, o])); } catch {} } // Build URLSearchParams for /api/packets given UI state. Pure function for // testability — returns the params object the next call to /api/packets // would use. The hash filter is an exact identifier: when present it // suppresses ALL other filters (region, time window, observer, node, // channel). The user is asking for THAT packet regardless of saved // selections. function buildPacketsParams({ filters, regionParam, windowMin, groupByHash, limit }) { const params = new URLSearchParams(); if (filters.hash) { params.set('hash', filters.hash); params.set('limit', String(limit)); if (groupByHash) { params.set('groupByHash', 'true'); } else { params.set('expand', 'observations'); } return params; } if (windowMin > 0) { const since = new Date(Date.now() - windowMin * 60000).toISOString(); params.set('since', since); } params.set('limit', String(limit)); if (regionParam) params.set('region', regionParam); if (filters.node) params.set('node', filters.node); if (filters.observer) params.set('observer', filters.observer); if (filters.channel) params.set('channel', filters.channel); if (groupByHash) { params.set('groupByHash', 'true'); } else { params.set('expand', 'observations'); } return params; } async function loadPackets() { try { const selectedWindow = Number(document.getElementById('fTimeWindow')?.value); const windowMin = Number.isFinite(selectedWindow) ? selectedWindow : savedTimeWindowMin; const params = buildPacketsParams({ filters, regionParam: RegionFilter.getRegionParam(), windowMin, groupByHash, limit: PACKET_LIMIT, }); 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, flatten observations inline (single API call, no N+1) if (!groupByHash) { const flat = []; for (const p of packets) { if (p.observations && p.observations.length > 1) { for (const o of p.observations) { flat.push(clearParsedCache({...p, ...o, _isObservation: true, observations: undefined})); } } else { flat.push(p); } } packets = flat; totalCount = flat.length; } // Pre-resolve from server-side resolved_path (preferred, no client-side disambiguation needed) await cacheResolvedPaths(packets); // Pre-resolve all path hops to node names (fallback for packets without resolved_path) const allHops = new Set(); for (const p of packets) { try { getParsedPath(p).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 = getParsedPath(p); 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 (parallel fetch, Map lookup) if (groupByHash && expandedHashes.size > 0) { const expandedArr = [...expandedHashes]; // Fetch the full packet detail (which includes per-observation rows) for each expanded hash. // Previously this used `/packets?hash=X&limit=20` which returned ONE aggregate row, causing // every "child" row in the table to carry the parent packet.id instead of unique observation // ids — so clicking any child pointed the side pane at the same aggregate. See #866. const results = await Promise.all(expandedArr.map(hash => { const group = hashIndex.get(hash); if (!group) return { hash, group: null, data: null }; return api(`/packets/${hash}`) .then(data => ({ hash, group, data })) .catch(() => ({ hash, group, data: null })); })); for (const { hash, group, data } of results) { if (!group) { expandedHashes.delete(hash); } else if (data) { const pkt = data.packet || group; // Build per-observation children. Spread (pkt, obs) so obs-level fields // (id, observer_id/name, path_json, snr/rssi, timestamp, raw_hex) override // the aggregate. Each child's `id` is the observation id (unique per observer). const obs = data.observations || []; group._children = obs.length ? obs.map(o => clearParsedCache({...pkt, ...o, _isObservation: true})) : [pkt]; group._fetchedData = { packet: pkt, observations: obs }; sortGroupChildren(group); } } } sortPacketsArray(); 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.
'; } finally { // Always signal data-loaded — even on error — so E2E tests can proceed. var pktContainer = document.getElementById('pktLeft') || document.getElementById('pktBody'); if (pktContainer) pktContainer.setAttribute('data-loaded', 'true'); } } 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 HB TypeObserverPathRptDetails
`; // Init shared RegionFilter component RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true }); if (_pendingUrlRegion) { RegionFilter.setSelected(_pendingUrlRegion.split(',').filter(Boolean)); _pendingUrlRegion = null; } RegionFilter.onChange(function() { updatePacketsUrl(); 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; // Restore Wireshark filter expression from URL if (filters._filterExpr) { pfInput.value = filters._filterExpr; var _restored = PacketFilter.compile(filters._filterExpr); if (!_restored.error) { pfInput.classList.add('filter-active'); filters._packetFilter = _restored.filter; } } 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; filters._filterExpr = undefined; updatePacketsUrl(); 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; filters._filterExpr = undefined; updatePacketsUrl(); renderTableRows(); } else { pfInput.classList.remove('filter-error'); pfInput.classList.add('filter-active'); pfError.style.display = 'none'; filters._packetFilter = compiled.filter; filters._filterExpr = expr; updatePacketsUrl(); renderTableRows(); } }, 300); }); })(); // Wireshark-style filter UX (#966): help popover, autocomplete, right-click // context menu, saved-filter dropdown. Idempotent — safe to re-call. if (window.FilterUX && typeof window.FilterUX.init === 'function') { window.FilterUX.init(); } // --- 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 = observerMap.get(id) || observerMap.get(Number(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(); updatePacketsUrl(); 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(); }); // --- Channel filter (#812) --- // Server-side filter: /api/packets?channel=. Triggers loadPackets() // (not just renderTableRows) so the filter applies before pagination. const channelSel = document.getElementById('fChannel'); if (channelSel) { if (filters.channel) { // Pre-seed an option so the current filter shows as selected even // before the channels list arrives. Replaced when populateChannels resolves. const opt = document.createElement('option'); opt.value = filters.channel; opt.textContent = filters.channel; opt.selected = true; channelSel.appendChild(opt); } api('/channels').then(data => { const channels = (data && data.channels) || []; // Build options via DOM API: channel names are network-supplied // and must NOT be interpolated into innerHTML (XSS, #812). // Sort alphabetically (case-insensitive) for predictable picker order; // the API returns last-activity order which is unstable for a dropdown. const sorted = channels.slice().sort((a, b) => { const an = (a.name || a.hash || '').toLowerCase(); const bn = (b.name || b.hash || '').toLowerCase(); return an < bn ? -1 : an > bn ? 1 : 0; }); channelSel.textContent = ''; const allOpt = document.createElement('option'); allOpt.value = ''; allOpt.textContent = 'All Channels'; channelSel.appendChild(allOpt); let matched = false; for (const ch of sorted) { const v = ch.hash || ch.name || ''; if (!v) continue; const opt = document.createElement('option'); opt.value = v; opt.textContent = ch.name || v; if (v === filters.channel) { opt.selected = true; matched = true; } channelSel.appendChild(opt); } // If current filter isn't in the list (encrypted hash, stale, or // race with cache), keep it as a selected option so the UI reflects state. if (filters.channel && !matched) { const opt = document.createElement('option'); opt.value = filters.channel; opt.textContent = filters.channel; opt.selected = true; channelSel.appendChild(opt); } }).catch(() => {}); channelSel.addEventListener('change', (e) => { filters.channel = e.target.value || undefined; updatePacketsUrl(); loadPackets(); }); } // Close multi-select menus on outside click bindDocumentHandler('menu', '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 ▾'; }); // --- Clear filters button --- const clearBtn = document.getElementById('clearFiltersBtn'); if (clearBtn) clearBtn.addEventListener('click', function() { // Reset filters object filters.hash = undefined; filters.node = undefined; filters.nodeName = undefined; filters.observer = undefined; filters.channel = undefined; filters.type = undefined; filters._filterExpr = undefined; filters._packetFilter = null; filters.myNodes = false; _observerFilterSet = null; // Clear localStorage filter entries localStorage.removeItem('meshcore-observer-filter'); localStorage.removeItem('meshcore-type-filter'); // Reset DOM inputs document.getElementById('fHash').value = ''; document.getElementById('fNode').value = ''; var pfInput = document.getElementById('packetFilterInput'); if (pfInput) { pfInput.value = ''; pfInput.classList.remove('filter-active', 'filter-error'); } var pfError = document.getElementById('packetFilterError'); if (pfError) pfError.style.display = 'none'; var pfCount = document.getElementById('packetFilterCount'); if (pfCount) pfCount.style.display = 'none'; document.getElementById('fChannel').value = ''; document.getElementById('fMyNodes').classList.remove('active'); // Reset observer multi-select var obMenu = document.getElementById('observerMenu'); if (obMenu) obMenu.querySelectorAll('input[type=checkbox]').forEach(function(cb) { cb.checked = false; }); document.getElementById('observerTrigger').textContent = 'All Observers ▾'; // Reset type multi-select var typeMenu = document.getElementById('typeMenu'); if (typeMenu) typeMenu.querySelectorAll('input[type=checkbox]').forEach(function(cb) { cb.checked = false; }); document.getElementById('typeTrigger').textContent = 'All Types ▾'; // Reset time window to default savedTimeWindowMin = DEFAULT_TIME_WINDOW; var fTW = document.getElementById('fTimeWindow'); if (fTW) fTW.value = String(DEFAULT_TIME_WINDOW); localStorage.removeItem('meshcore-time-window'); // Reset region filter RegionFilter.setSelected([]); // Update URL and reload updatePacketsUrl(); loadPackets(); }); // Show clear button if page loaded with active filters (e.g. from URL params) updatePacketsUrl(); // Filter event listeners document.getElementById('fHash').value = filters.hash || ''; document.getElementById('fHash').addEventListener('input', debounce((e) => { filters.hash = e.target.value || undefined; updatePacketsUrl(); loadPackets(); }, 300)); // Time window dropdown — restore from localStorage and bind change const fTimeWindow = document.getElementById('fTimeWindow'); fTimeWindow.value = String(savedTimeWindowMin); fTimeWindow.addEventListener('change', () => { savedTimeWindowMin = Number(fTimeWindow.value); if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15; localStorage.setItem('meshcore-time-window', fTimeWindow.value); updatePacketsUrl(); 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, batch-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); if (toFetch.length > 0) { const hashes = toFetch.map(p => p.hash); try { const resp = await fetch('/api/packets/observations', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({hashes}) }); if (resp.ok) { const data = await resp.json(); const results = data.results || {}; for (const p of toFetch) { const obs = results[p.hash]; if (obs && obs.length) { p._children = obs.map(o => clearParsedCache({...p, ...o, _isObservation: true})); p._fetchedData = {packet: p, observations: obs}; } } } } 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 { getParsedPath(p).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 isNarrow = window.innerWidth <= 640; const defaultHidden = isNarrow ? ['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'); }); bindDocumentHandler('colmenu', '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; updatePacketsUrl(); 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; updatePacketsUrl(); 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 = hashIndex.get(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, resolved_path: child.resolved_path, direction: child.direction, timestamp: child.timestamp, first_seen: child.timestamp} : child; if (parentData) { clearParsedCache(obsPacket); } selectPacket(child.id, parentHash, {packet: obsPacket, 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') { closeDetailPanel(); } }); renderTableRows(); makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths'); // #1056: register fluid-column responsive behavior (drops priority>1 cols // when narrow, shows "+N hidden" pill, reveals on click). Idempotent. if (window.TableResponsive) { var _pktTbl = document.getElementById('pktTable'); if (_pktTbl) window.TableResponsive.register(_pktTbl); } // Initialize table sorting (virtual scroll — sort data array, not DOM) if (window.TableSort) { var pktTableEl = document.getElementById('pktTable'); if (pktTableEl) { if (_tableSortInstance) _tableSortInstance.destroy(); _tableSortInstance = TableSort.init(pktTableEl, { defaultColumn: 'time', defaultDirection: 'desc', storageKey: 'meshcore-packets-sort', domReorder: false, onSort: function(column, direction) { _packetSortColumn = column; _packetSortDirection = direction; sortPacketsArray(); renderTableRows(); updatePacketsUrl(); } }); // Apply initial sort state from TableSort if (_tableSortInstance) { var st = _tableSortInstance.getState(); _packetSortColumn = st.column; _packetSortDirection = st.direction; sortPacketsArray(); } } } } // Build HTML for a single grouped packet row function buildGroupRowHtml(p, entryIdx = -1) { const isExpanded = expandedHashes.has(p.hash); let headerObserverId = p.observer_id; let headerPathJson = p.path_json; if (_observerFilterSet && p._children?.length) { const match = p._children.find(c => _observerFilterSet.has(String(c.observer_id))); if (match) { headerObserverId = match.observer_id; headerPathJson = match.path_json; } } const groupRegion = headerObserverId ? (observerMap.get(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 groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; const isSingle = p.count <= 1; // Channel color highlighting (#271) const _grpDecoded = getParsedDecoded(p) || {}; const _grpChanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(_grpDecoded.type || groupTypeName, _grpDecoded.channel) : ''; const _grpHashStripe = _hashStripeStyle(p.hash); const _grpStyle = _grpHashStripe + _grpChanStyle; let html = ` ${isSingle ? '' : (isExpanded ? '▼' : '▶')} ${groupRegion ? `${groupRegion}` : '—'} ${renderTimestampCell(p.latest)} ${truncate(p.hash || '—', 8)} ${groupSize ? groupSize + 'B' : '—'} ${groupHashBytes} ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} ${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(getParsedDecoded(p))} `; if (isExpanded && p._children) { let visibleChildren = p._children; if (_observerFilterSet) { visibleChildren = visibleChildren.filter(c => _observerFilterSet.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 childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : ''; const childPath = getParsedPath(c); const childPathStr = renderPath(childPath, c.observer_id); const _childHashStripe = _hashStripeStyle(c.hash || p.hash); html += ` ${childRegion ? `${childRegion}` : '—'} ${renderTimestampCell(c.timestamp)} ${truncate(c.hash || '', 8)} ${size}B ${childHashBytes} ${typeName}${transportBadge(c.route_type)} ${truncate(obsName(c.observer_id), 16)} ${childPathStr} ${getDetailPreview(getParsedDecoded(c))} `; } } return html; } // Build HTML for a single flat (ungrouped) packet row function buildFlatRowHtml(p, entryIdx = -1) { const decoded = getParsedDecoded(p) || {}; const pathHops = getParsedPath(p) || []; const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : ''; const typeName = payloadTypeName(p.payload_type); const typeClass = payloadTypeColor(p.payload_type); // Channel color highlighting (#271) const _chanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(decoded.type || typeName, decoded.channel) : ''; const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded); const _flatHashStripe = _hashStripeStyle(p.hash); const _flatStyle = _flatHashStripe + _chanStyle; return ` ${region ? `${region}` : '—'} ${renderTimestampCell(p.timestamp)} ${truncate(p.hash || String(p.id), 8)} ${size}B ${hashBytes} ${typeName}${transportBadge(p.route_type)} ${truncate(obsName(p.observer_id), 16)} ${pathStr} ${detail} `; } // Mark _rowCounts as stale so renderVisibleRows() recomputes them lazily. // Called when expanded group children change outside renderTableRows() (#410). function _invalidateRowCounts() { _rowCountsDirty = true; _cumulativeOffsetsCache = null; } // Recompute _rowCounts from _displayPackets if they've been invalidated. function _refreshRowCountsIfDirty() { if (!_rowCountsDirty || !_displayPackets.length) return; _rowCounts = _displayPackets.map(function(p) { return _getRowCount(p); }); _cumulativeOffsetsCache = null; _rowCountsDirty = false; } // Compute the number of DOM rows a single entry produces. // Used by both row counting and renderVisibleRows to avoid divergence (#424). function _getRowCount(p) { if (!_displayGrouped) return 1; if (!expandedHashes.has(p.hash) || !p._children) return 1; let childCount = p._children.length; if (_observerFilterSet) { childCount = p._children.filter(c => _observerFilterSet.has(String(c.observer_id))).length; } return 1 + childCount; } // Get the column count from the thead (dynamic, avoids hardcoded colspan — #426) function _getColCount() { const thead = document.querySelector('#pktLeft thead tr'); return thead ? thead.children.length : 11; } // Compute cumulative DOM row offsets from per-entry row counts. // Returns array where cumulativeOffsets[i] = total rows before entry i. function _cumulativeRowOffsets() { if (_cumulativeOffsetsCache) return _cumulativeOffsetsCache; const offsets = new Array(_rowCounts.length + 1); offsets[0] = 0; for (let i = 0; i < _rowCounts.length; i++) { offsets[i + 1] = offsets[i] + _rowCounts[i]; } _cumulativeOffsetsCache = offsets; return offsets; } function renderVisibleRows() { const _rvr_t0 = performance.now(); const tbody = document.getElementById('pktBody'); if (!tbody || !_displayPackets.length) return; const scrollContainer = document.getElementById('pktLeft'); if (!scrollContainer) return; // Recompute row counts if they were invalidated (e.g. WS added children) (#410) _refreshRowCountsIfDirty(); // Compute total DOM rows accounting for expanded groups const offsets = _cumulativeRowOffsets(); const totalDomRows = offsets[offsets.length - 1]; const totalHeight = totalDomRows * VSCROLL_ROW_HEIGHT; const colCount = _getColCount(); // Get or create spacer elements let topSpacer = document.getElementById('vscroll-top'); let bottomSpacer = document.getElementById('vscroll-bottom'); if (!topSpacer) { topSpacer = document.createElement('tr'); topSpacer.id = 'vscroll-top'; // aria-hidden + visibility:hidden so Playwright/AT treat the sentinel as invisible // while preserving its layout role (the inner height drives virtual-scroll padding). topSpacer.setAttribute('aria-hidden', 'true'); topSpacer.style.visibility = 'hidden'; topSpacer.innerHTML = ''; } if (!bottomSpacer) { bottomSpacer = document.createElement('tr'); bottomSpacer.id = 'vscroll-bottom'; bottomSpacer.setAttribute('aria-hidden', 'true'); bottomSpacer.style.visibility = 'hidden'; bottomSpacer.innerHTML = ''; } // Calculate visible range based on scroll position const scrollTop = scrollContainer.scrollTop; const viewportHeight = scrollContainer.clientHeight; // Account for thead height (measured dynamically) const theadEl = scrollContainer.querySelector('thead'); if (theadEl) _vscrollTheadHeight = theadEl.offsetHeight || _vscrollTheadHeight; const { startIdx, endIdx } = _calcVisibleRange( offsets, _displayPackets.length, scrollTop, viewportHeight, VSCROLL_ROW_HEIGHT, _vscrollTheadHeight, VSCROLL_BUFFER ); // Skip DOM rebuild if visible range hasn't changed if (startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd) { if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: skip (no change) %.2fms', performance.now() - _rvr_t0); return; } const prevStart = _lastVisibleStart; const prevEnd = _lastVisibleEnd; _lastVisibleStart = startIdx; _lastVisibleEnd = endIdx; // Compute padding using cumulative row counts const topPad = offsets[startIdx] * VSCROLL_ROW_HEIGHT; const bottomPad = (totalDomRows - offsets[endIdx]) * VSCROLL_ROW_HEIGHT; topSpacer.firstChild.style.height = topPad + 'px'; bottomSpacer.firstChild.style.height = bottomPad + 'px'; const builder = _displayGrouped ? buildGroupRowHtml : buildFlatRowHtml; const hasOverlap = prevStart !== -1 && startIdx < prevEnd && endIdx > prevStart; if (!hasOverlap) { // Full rebuild: initial render or large scroll jump past buffer const visibleHtml = _displayPackets.slice(startIdx, endIdx) .map((p, i) => builder(p, startIdx + i)).join(''); tbody.innerHTML = ''; tbody.appendChild(topSpacer); tbody.insertAdjacentHTML('beforeend', visibleHtml); tbody.appendChild(bottomSpacer); // Measure actual row height from first rendered data row (#407) if (!_vscrollRowHeightMeasured) { const firstRow = topSpacer.nextElementSibling; if (firstRow && firstRow !== bottomSpacer) { const h = firstRow.offsetHeight; if (h > 0) { VSCROLL_ROW_HEIGHT = h; _vscrollRowHeightMeasured = true; } } } if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: full rebuild %d entries, %.2fms', endIdx - startIdx, performance.now() - _rvr_t0); return; } // Incremental update: remove rows that scrolled out at the top (positional) const headRowCount = offsets[Math.min(startIdx, prevEnd)] - offsets[prevStart]; for (let r = 0; r < headRowCount; r++) { const row = topSpacer.nextElementSibling; if (row && row !== bottomSpacer) row.remove(); } // Remove rows that scrolled out at the bottom (positional) const tailFrom = Math.max(endIdx, prevStart); const tailRowCount = offsets[prevEnd] - offsets[tailFrom]; for (let r = 0; r < tailRowCount; r++) { const row = bottomSpacer.previousElementSibling; if (row && row !== topSpacer) row.remove(); } // Prepend rows that scrolled into view at the top if (startIdx < prevStart) { let html = ''; for (let i = startIdx; i < Math.min(prevStart, endIdx); i++) { html += builder(_displayPackets[i], i); } topSpacer.insertAdjacentHTML('afterend', html); } // Append rows that scrolled into view at the bottom if (endIdx > prevEnd) { let html = ''; for (let i = Math.max(prevEnd, startIdx); i < endIdx; i++) { html += builder(_displayPackets[i], i); } bottomSpacer.insertAdjacentHTML('beforebegin', html); } if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: incremental head=%d tail=%d, %.2fms', headRowCount, tailRowCount, performance.now() - _rvr_t0); } // Attach/detach scroll listener for virtual scrolling function attachVScrollListener() { const scrollContainer = document.getElementById('pktLeft'); if (!scrollContainer) return; if (_vsScrollHandler) return; // already attached let scrollRaf = null; _vsScrollHandler = function () { if (scrollRaf) return; scrollRaf = requestAnimationFrame(function () { scrollRaf = null; renderVisibleRows(); }); }; scrollContainer.addEventListener('scroll', _vsScrollHandler, { passive: true }); } function detachVScrollListener() { if (!_vsScrollHandler) return; const scrollContainer = document.getElementById('pktLeft'); if (scrollContainer) scrollContainer.removeEventListener('scroll', _vsScrollHandler); _vsScrollHandler = null; } /** Sort the packets array by the current sort column. Called before renderTableRows. */ function sortPacketsArray() { if (!_packetSortColumn || !packets.length) return; var col = _packetSortColumn; var dir = _packetSortDirection === 'asc' ? 1 : -1; var accessor; switch (col) { case 'time': accessor = function(p) { return p.latest || p.timestamp || ''; }; break; case 'type': accessor = function(p) { return typeName(p.payload_type); }; break; case 'hash': accessor = function(p) { return p.hash || ''; }; break; case 'observer': accessor = function(p) { return obsName(p.observer_id); }; break; case 'size': accessor = function(p) { return p.packet_size || 0; }; break; case 'hb': accessor = function(p) { return p.hash_byte_count != null ? p.hash_byte_count : (p.hash_size || 0); }; break; case 'rpt': accessor = function(p) { try { var pj = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : p.path_json; return Array.isArray(pj) ? pj.length : 0; } catch(e) { return 0; } }; break; case 'region': accessor = function(p) { return (regionMap && regionMap[p.observer_id]) || ''; }; break; case 'path': accessor = function(p) { try { var pj = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : p.path_json; return Array.isArray(pj) ? pj.join(',') : ''; } catch(e) { return ''; } }; break; default: return; // unsortable column } // Choose comparator based on column type var isNumeric = (col === 'size' || col === 'hb' || col === 'rpt'); var isDate = (col === 'time'); packets.sort(function(a, b) { var va = accessor(a), vb = accessor(b); var result; if (isDate) { result = TableSort.comparators.date(va, vb); } else if (isNumeric) { result = TableSort.comparators.numeric(va, vb); } else { result = TableSort.comparators.text(va, vb); } // Stable tiebreaker: sort by timestamp (desc) when primary values are equal if (result === 0 && !isDate) { result = TableSort.comparators.date( a.timestamp || a.first_seen || '', b.timestamp || b.first_seen || '' ) * -1; // desc (newest first) } return dir * result; }); } async function renderTableRows() { const tbody = document.getElementById('pktBody'); if (!tbody) return; // Preserve scroll position across re-render (#431) const scrollContainer = document.getElementById('pktLeft'); const savedScrollTop = scrollContainer ? scrollContainer.scrollTop : 0; // 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 — pure client-side filter (no server round-trip) let displayPackets = packets; // When loading a specific packet by hash, bypass ALL client-side filters // (myNodes, type, observer, packet-filter-expression). The user is asking // for THAT exact packet — saved type/observer/expression filters must not // hide it. Hash filter is the exact identifier; nothing else applies. const hashOnly = !!filters.hash; if (!hashOnly && 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) { displayPackets = displayPackets.filter(p => { const dj = p.decoded_json || ''; return allKeys.some(k => dj.includes(k)); }); } else { displayPackets = []; } } // Client-side type/observer filtering if (!hashOnly && filters.type) { const types = filters.type.split(',').map(Number); displayPackets = displayPackets.filter(p => types.includes(p.payload_type)); } if (!hashOnly && filters.observer) { const obsIds = new Set(filters.observer.split(',')); displayPackets = displayPackets.filter(p => { if (obsIds.has(p.observer_id)) return true; if (p._children) return p._children.some(c => obsIds.has(String(c.observer_id))); return false; }); } // Packet Filter Language const pfCount = document.getElementById('packetFilterCount'); if (!hashOnly && 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) { _displayPackets = []; _rowCounts = []; _rowCountsDirty = false; _cumulativeOffsetsCache = null; _observerFilterSet = null; _lastVisibleStart = -1; _lastVisibleEnd = -1; detachVScrollListener(); const colCount = _getColCount(); tbody.innerHTML = '' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + ''; // Restore scroll position after DOM rebuild (#431) if (scrollContainer) scrollContainer.scrollTop = savedScrollTop; return; } // Lazy virtual scroll: store display packets and row counts, but do NOT // pre-generate HTML strings. HTML is built on-demand in renderVisibleRows() // for only the visible slice + buffer (#422). _lastVisibleStart = -1; _lastVisibleEnd = -1; _displayPackets = displayPackets; _displayGrouped = groupByHash; _observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null; _rowCounts = displayPackets.map(p => _getRowCount(p)); _rowCountsDirty = false; _cumulativeOffsetsCache = null; attachVScrollListener(); renderVisibleRows(); // Restore scroll position after re-render (#431) if (scrollContainer) scrollContainer.scrollTop = savedScrollTop; } 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)}`; } // Undecrypted channel messages — show channel hash and decryption status if (decoded.type === 'GRP_TXT' && decoded.channelHash != null) { const hashHex = decoded.channelHashHex || decoded.channelHash.toString(16).padStart(2, '0').toUpperCase(); const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' : 'decryption failed'; return `🔒 Ch 0x${hashHex} (${statusLabel})`; } // 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'); var layout = panel.closest('.split-layout'); if (layout) layout.classList.remove('detail-collapsed'); panel.innerHTML = '
' + PANEL_CLOSE_HTML + '
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 = getParsedPath(pkt); const newHops = hops.filter(h => !(h in hopNameCache)); if (newHops.length) await resolveHops(newHops); } catch {} panel.innerHTML = isMobileNow ? '' : '
' + PANEL_CLOSE_HTML; const content = document.createElement('div'); panel.appendChild(content); await renderDetail(content, data, selectedObservationId); if (!isMobileNow) initPanelResize(); } catch (e) { panel.innerHTML = `
Error: ${e.message}
`; } } async function renderDetail(panel, data, chosenObsId) { const pkt = data.packet; const observations = data.observations || []; // Per-observation rendering (issue #849): // When opened from a packet row (no specific observer), default to first observation. // When opened from an observation child row, use that observation. // Clicking a different observation row in the detail re-renders with that observation. let currentObs = null; const targetObsId = chosenObsId || selectedObservationId; if (targetObsId && observations.length) { currentObs = observations.find(o => String(o.id) === String(targetObsId)); } if (!currentObs && observations.length) { currentObs = observations[0]; // fall back to first observation } // If we have a current observation, build pkt fields from it so summary is per-observation const effectivePkt = currentObs ? clearParsedCache({...pkt, ...currentObs, _isObservation: true}) : pkt; const decoded = getParsedDecoded(effectivePkt) || {}; const pathHops = getParsedPath(effectivePkt) || []; // Compute breakdown ranges from the actually-rendered raw_hex (per-observation). // Single source of truth — derived from the same bytes we display, so a // post-#882 per-obs raw_hex with a different path length than the top-level // packet's raw_hex still gets accurate byte highlights. const obsRawHexForRanges = effectivePkt.raw_hex || pkt.raw_hex || ''; const ranges = obsRawHexForRanges ? computeBreakdownRanges(obsRawHexForRanges, pkt.route_type, pkt.payload_type) : []; // Cross-check: hop count from raw_hex path_len byte vs path_json length const obsRawHex = effectivePkt.raw_hex || pkt.raw_hex || ''; let rawHopCount = null; if (obsRawHex.length >= 4) { // path_len byte position depends on route type const plOff = getPathLenOffset(pkt.route_type); const plByte = parseInt(obsRawHex.slice(plOff * 2, plOff * 2 + 2), 16); if (!isNaN(plByte)) rawHopCount = plByte & 0x3F; } if (rawHopCount != null && pathHops.length !== rawHopCount) { console.warn(`[CoreScope] Hop count inconsistency for packet ${pkt.hash}: path_json has ${pathHops.length} hops but raw_hex path_len has ${rawHopCount}. UI shows path_json.`); } // 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 {} } // Resolve hops: prefer server-side resolved_path, fall back to client-side HopResolver if (pathHops.length) { try { const serverResolved = getResolvedPath(pkt); let resolved; if (serverResolved && serverResolved.length === pathHops.length) { await ensureHopResolver(); resolved = HopResolver.resolveFromServer(pathHops, serverResolved); } else { await ensureHopResolver(); 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 plOff = getPathLenOffset(pkt.route_type); const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(plOff * 2, plOff * 2 + 2), 16) : NaN; const hashSize = (isNaN(rawPathByte) || (rawPathByte & 0x3F) === 0) ? null : ((rawPathByte >> 6) + 1); const size = effectivePkt.raw_hex ? Math.floor(effectivePkt.raw_hex.length / 2) : (pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0); const typeName = payloadTypeName(pkt.payload_type); const snr = effectivePkt.snr ?? decoded.SNR ?? decoded.snr ?? null; const rssi = effectivePkt.rssi ?? decoded.RSSI ?? decoded.rssi ?? null; const hasRawHex = !!(effectivePkt.raw_hex || 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}
` : ''}
`; } else if (decoded.type === 'GRP_TXT' && decoded.channelHash != null) { const hashHex = decoded.channelHashHex || decoded.channelHash.toString(16).padStart(2, '0').toUpperCase(); const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' : 'decryption failed'; messageHtml = `
🔒 Channel Hash: 0x${hashHex} (${statusLabel})
`; } 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 {} } } const anomalyBanner = decoded.anomaly ? `
⚠️ Anomaly: ${escapeHtml(decoded.anomaly)}
` : ''; // Hop count display: use pathHops length (= effective observation's path_json). // The raw_hex/path_json mismatch warning is logged above for diagnostics; the UI // must stay self-consistent — top pill names and byte breakdown rows must agree. const displayHopCount = pathHops.length; const obsIndicator = currentObs && observations.length > 1 ? `(observation ${observations.indexOf(currentObs) + 1} of ${observations.length})` : ''; panel.innerHTML = ` ${anomalyBanner}
${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}
${pkt.hash || 'Packet #' + pkt.id}${obsIndicator}
${messageHtml}
Observer
${obsName(effectivePkt.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
${renderTimestampCell(effectivePkt.timestamp)}
Propagation
${propagationHtml}
Path
${displayHopCount > 0 ? `${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''} ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}
${effectivePkt.direction ? `
Direction
${escapeHtml(effectivePkt.direction)}
` : ''}
${pathHops.length ? `` : ''} ${pkt.hash ? `🔍 Trace` : ''}
${hasRawHex ? `
${buildHexLegend(ranges)}
${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}
` : ''} ${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)} ${observations.length > 1 ? `
Observations (${observations.length})
${observations.map(o => { const oPath = getParsedPath(o); const isCurrent = currentObs && String(o.id) === String(currentObs.id); return ``; }).join('')}
Observer Hops SNR RSSI Time
${obsName(o.observer_id)} ${oPath.length} ${o.snr != null ? o.snr + ' dB' : '—'} ${o.rssi != null ? o.rssi + ' dBm' : '—'} ${renderTimestampCell(o.timestamp)}
` : ''} ${observations.length > 1 ? (() => { // Cross-observer aggregate (Option B): show longest observed path across all observers const aggregatePath = getParsedPath(pkt) || []; return `
Cross-observer aggregate
Longest observed path: ${aggregatePath.length ? `${aggregatePath.length} hops — ${renderPath(aggregatePath, pkt.observer_id)}` : '— (direct)'}
Longest path seen across all ${uniqueObservers} observer${uniqueObservers !== 1 ? 's' : ''}
`; })() : ''} `; // Wire up observation row click handlers — re-render detail with clicked observation panel.querySelectorAll('.detail-obs-row').forEach(row => { row.addEventListener('click', () => { const obsId = row.dataset.obsId; selectedObservationId = obsId; // Update URL hash to reflect selected observation (deep linking) const pktHash = pkt.hash || pkt.id; const obsParam = obsId ? `?obs=${obsId}` : ''; history.replaceState(null, '', `#/packets/${pktHash}${obsParam}`); renderDetail(panel, data, obsId); }); }); // 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}`; window.copyToClipboard(url, () => { copyLinkBtn.textContent = '✅ Copied!'; setTimeout(() => { copyLinkBtn.textContent = '🔗 Copy Link'; }, 1500); }); }); } // 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) { const oPath = getParsedPath(o); const oDec = getParsedDecoded(o); 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 = panel.querySelector('#viewRouteBtn'); if (routeBtn && pathHops.length) { routeBtn.addEventListener('click', async () => { try { // Prefer server-side resolved_path if available const serverResolved = getResolvedPath(pkt); let resolvedKeys; if (serverResolved && serverResolved.length === pathHops.length) { // Use server-resolved pubkeys, fall back to short prefix for null entries resolvedKeys = pathHops.map((h, i) => serverResolved[i] || h); } else { // Fall back to client-side HopResolver const senderLat = decoded.lat || decoded.latitude; const senderLon = decoded.lon || decoded.longitude; let obsLat = null, obsLon = null; const obsId = obsName(pkt.observer_id); await ensureHopResolver(); const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) }; 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', 'section-header'); rows += fieldRow(0, 'Header Byte', '0x' + (buf.slice(0, 2) || '??'), `Route: ${routeTypeName(pkt.route_type)}, Payload: ${payloadTypeName(pkt.payload_type)}`); // Transport codes come BEFORE path length for transport routes (bytes 1-4) let off = 1; if (isTransportRoute(pkt.route_type)) { rows += sectionRow('Transport Codes', 'section-transport'); 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 length byte is at current offset (byte 1 for non-transport, byte 5 for transport) const pathLenOffset = off; const pathByte0 = parseInt(buf.slice(off * 2, off * 2 + 2), 16); const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1); const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F); rows += fieldRow(off, 'Path Length', '0x' + (buf.slice(off * 2, off * 2 + 2) || '??'), hashCountVal === 0 ? `hash_count=0 (direct advert)` : `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`); off += 1; // Path — render hops from path_json (what this observation reported). // Byte offsets advance by hashSize * pathHops.length to match. const hashSize = isNaN(pathByte0) ? 1 : ((pathByte0 >> 6) + 1); if (pathHops.length > 0) { rows += sectionRow('Path (' + pathHops.length + ' hops)', 'section-path'); for (let i = 0; i < pathHops.length; i++) { const hopOff = off + i * hashSize; const hex = String(pathHops[i] || '').toUpperCase(); const hopHtml = HopDisplay.renderHop(hex, hopNameCache[hex]); const label = `Hop ${i} — ${hopHtml}`; rows += fieldRow(hopOff, label, hex, ''); } off += hashSize * pathHops.length; } // TRACE SNR values (from header path bytes, decoded by backend) if (decoded.type === 'TRACE' && decoded.snrValues && decoded.snrValues.length > 0) { rows += sectionRow('SNR Path (' + decoded.snrValues.length + ' hops completed)', 'section-path'); for (let i = 0; i < decoded.snrValues.length; i++) { const snr = decoded.snrValues[i]; const snrStr = (snr >= 0 ? '+' : '') + snr.toFixed(2) + ' dB'; rows += fieldRow('', 'SNR (hop ' + i + ')', snrStr, ''); } } // Payload rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload'); if (decoded.type === 'ADVERT') { if (hashCountVal !== 0) rows += fieldRow(pathLenOffset, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(pathLenOffset * 2, pathLenOffset * 2 + 2) || '??') + ' — 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') { const hashHex = decoded.channelHashHex || (decoded.channelHash != null ? decoded.channelHash.toString(16).padStart(2, '0').toUpperCase() : '??'); const statusLabel = decoded.decryptionStatus === 'no_key' ? '(no key)' : decoded.decryptionStatus === 'decryption_failed' ? '(decryption failed)' : ''; rows += fieldRow(off, 'Channel Hash', `0x${hashHex} ${statusLabel}`, ''); 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, 'Checksum (4B)', decoded.ackChecksum || '', ''); } else if (decoded.type === 'TRACE') { rows += fieldRow(off, 'Trace Tag (4B)', decoded.tag ? '0x' + decoded.tag.toString(16).toUpperCase().padStart(8, '0') : '—', ''); rows += fieldRow(off + 4, 'Auth Code (4B)', decoded.authCode ? '0x' + decoded.authCode.toString(16).toUpperCase().padStart(8, '0') : '—', ''); rows += fieldRow(off + 8, 'Flags', decoded.traceFlags != null ? '0x' + decoded.traceFlags.toString(16).padStart(2, '0') : '—', decoded.traceFlags != null ? 'hash_size=' + (1 << (decoded.traceFlags & 0x03)) + ' byte(s)' : ''); if (decoded.pathData) { rows += fieldRow(off + 9, 'Route Hops', decoded.pathData.toUpperCase(), pathHops.length + ' hop(s)'); } } else if (decoded.destHash !== undefined) { rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', ''); rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', ''); rows += fieldRow(off + 2, 'MAC (2B)', decoded.mac || '', ''); rows += fieldRow(off + 4, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), ''); } else { rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), ''); } if (decoded.anomaly) { rows += `⚠️ Anomaly${escapeHtml(decoded.anomaly)}`; } return `${rows}
OffsetFieldValueDescription
`; } function sectionRow(label, cls) { return `${label}`; } function fieldRow(offset, name, value, desc) { return `${offset}${name}${value}${desc || ''}`; } // BYOP modal — decode only, no DB injection function showBYOP() { removeAllByopOverlays(); const triggerBtn = document.querySelector('[data-action="pkt-byop"]'); const overlay = document.createElement('div'); overlay.className = 'modal-overlay byop-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); const modal = overlay.querySelector('.byop-modal'); const close = () => { removeAllByopOverlays(); 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 = overlay.querySelector('#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 = '
'; // Anomaly banner if (d.anomaly) { html += '
⚠️ Anomaly: ' + escapeHtml(d.anomaly) + '
'; } // 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)); } } // Special handling for advert signature validation if (h.payloadType === 4 && p.signatureValid !== undefined) { const status = p.signatureValid ? 'Valid' : 'Invalid'; const badgeClass = p.signatureValid ? 'badge-success' : 'badge-danger'; html += kv('Signature', `${status}`); } 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 getParsedPath(c).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 try { const data = await api(`/packets/${hash}`); const pkt = data.packet; if (!pkt) return; const group = hashIndex.get(hash); if (group && data.observations) { group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true})); group._fetchedData = data; // Sort children based on current sort mode sortGroupChildren(group); } // Resolve hops from children: prefer server-side resolved_path await cacheResolvedPaths(group?._children || []); const childHops = new Set(); for (const c of (group?._children || [])) { try { getParsedPath(c).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 _lastColorByHash = _isColorByHash(); function _onStorageChange() { var current = _isColorByHash(); if (_lastColorByHash !== current) { _lastColorByHash = current; renderVisibleRows(); } } let _themeRefreshHandler = null; registerPage('packets', { init: function(app, routeParam) { _themeRefreshHandler = () => { if (typeof renderTableRows === 'function') renderTableRows(); }; window.addEventListener('theme-refresh', _themeRefreshHandler); window.addEventListener('storage', _onStorageChange); var result = init(app, routeParam); // Install channel color picker on packets table (M2, #271) if (window.ChannelColorPicker) window.ChannelColorPicker.installPacketsTable(); return result; }, destroy: function() { if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } window.removeEventListener('storage', _onStorageChange); return destroy(); } }); // Standalone packet detail page: #/packet/123 or #/packet/HASH // Expose pure functions for unit testing (vm.createContext pattern) if (typeof window !== 'undefined') { document.addEventListener('channel-colors-changed', function() { renderVisibleRows(); }); window._packetsTestAPI = { typeName, obsName, getDetailPreview, sortGroupChildren, getPathHopCount, renderDecodedPacket, kv, buildFieldTable, sectionRow, fieldRow, renderTimestampCell, renderPath, _getRowCount, _cumulativeRowOffsets, _invalidateRowCounts, _refreshRowCountsIfDirty, buildGroupRowHtml, buildFlatRowHtml, _calcVisibleRange, buildPacketsParams, renderTableRows, _setPackets: function(p) { packets = p; }, _setFilter: function(k, v) { filters[k] = v; }, }; } 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 { hops.push(...getParsedPath(data.packet)); } 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: () => {} }); })();