/* === CoreScope — packets.js === */
'use strict';
(function () {
let packets = [];
let hashIndex = new Map(); // hash → packet group for O(1) dedup
// Resolve observer_id to friendly name from loaded observers list
function obsName(id) {
if (!id) return '—';
const o = observerMap.get(id);
if (!o) return id;
return o.iata ? `${o.name} (${o.iata})` : o.name;
}
let selectedId = null;
let groupByHash = true;
let filters = {};
{ const o = localStorage.getItem('meshcore-observer-filter'); if (o) filters.observer = o;
const t = localStorage.getItem('meshcore-type-filter'); if (t) filters.type = t; }
let wsHandler = null;
let packetsPaused = false;
let pauseBuffer = [];
let observers = [];
let 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', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
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._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr));
return parts.length ? '?' + parts.join('&') : '';
}
window.buildPacketsQuery = buildPacketsQuery;
function updatePacketsUrl() {
history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
}
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 _urlFilterExpr = _initUrlParams.get('filter');
if (_urlFilterExpr) filters._filterExpr = _urlFilterExpr;
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, timestamp: obs.timestamp, first_seen: obs.timestamp};
clearParsedCache(obsPacket);
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
} else {
selectPacket(data.packet.id, h, data);
}
} else {
selectPacket(data.packet.id, h, data);
}
}
} catch {}
}
// Event delegation for data-action buttons
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 => {
// 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 (filters.hash && p.hash !== filters.hash) 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(p);
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 {}
}
async function loadPackets() {
try {
const params = new URLSearchParams();
const selectedWindow = Number(document.getElementById('fTimeWindow')?.value);
const windowMin = Number.isFinite(selectedWindow) ? selectedWindow : savedTimeWindowMin;
if (windowMin > 0 && !filters.hash) {
const since = new Date(Date.now() - windowMin * 60000).toISOString();
params.set('since', since);
}
params.set('limit', String(PACKET_LIMIT));
const regionParam = RegionFilter.getRegionParam();
if (regionParam) params.set('region', regionParam);
if (filters.hash) params.set('hash', filters.hash);
if (filters.node) params.set('node', filters.node);
if (filters.observer) params.set('observer', filters.observer);
if (groupByHash) {
params.set('groupByHash', 'true');
} else {
params.set('expand', 'observations');
}
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];
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=${hash}&limit=20`)
.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) {
group._children = data.packets || [];
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.
';
}
}
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 = `
Latest Packets (${totalCount})
ⓘ
Region
Time
Hash
Size
HB
Type
Observer
Path
Rpt
Details
`;
// 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);
});
})();
// --- 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();
});
// 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 ▾';
});
// 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) =>
`
`;
}
// 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';
topSpacer.innerHTML = '