Files
meshcore-analyzer/public/packets.js
Kpa-clawbot 71ec5e6fca rename: MeshCore Analyzer → CoreScope (frontend + .squad)
Phase 1 of the CoreScope rename — frontend display strings and
squad agent metadata only.

index.html:
- <title>, og:title, twitter:title → CoreScope
- Brand text span → CoreScope
- og:image/twitter:image URLs → corescope repo (placeholder)
- Cache busters bumped

public/*.js headers (19 files):
- All file header comments updated

public/*.css headers:
- style.css, home.css updated

JavaScript strings:
- app.js: GitHub URL → corescope
- home.js: 3 fallback siteName references
- customize.js: default siteName + heroTitle

Tests:
- test-e2e-playwright.js: title assertion → corescope
- test-frontend-helpers.js: GitHub URL constant
- benchmark.js: header string
- test-all.sh: header string

.squad:
- team.md, casting/history.json
- All 7 agent charters + 5 history files

NOT renamed (intentional):
- localStorage keys (meshcore-*)
- CSS classes (.meshcore-marker)
- Window globals (_meshcore*)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 14:03:32 -07:00

1812 lines
84 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === 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 = observers.find(ob => ob.id === id);
if (!o) return id;
return o.iata ? `${o.name} (${o.iata})` : o.name;
}
let selectedId = null;
let groupByHash = true;
let filters = {};
{ const o = localStorage.getItem('meshcore-observer-filter'); if (o) filters.observer = o;
const t = localStorage.getItem('meshcore-type-filter'); if (t) filters.type = t; }
let wsHandler = null;
let packetsPaused = false;
let pauseBuffer = [];
let observers = [];
let regionMap = {};
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
let totalCount = 0;
let expandedHashes = new Set();
let hopNameCache = {};
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
let filtersBuilt = false;
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
function closeDetailPanel() {
var panel = document.getElementById('pktRight');
if (panel) {
panel.classList.add('empty');
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML + '<span>Select a packet to view details</span>';
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; });
}
}
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('<span class="arrow">→</span>');
}
let directPacketId = null;
let directPacketHash = null;
let initGeneration = 0;
let directObsId = null;
async function init(app, routeParam) {
const gen = ++initGeneration;
// Parse ?obs=OBSERVER_ID from routeParam
if (routeParam && routeParam.includes('?')) {
const qIdx = routeParam.indexOf('?');
const qs = new URLSearchParams(routeParam.substring(qIdx));
directObsId = qs.get('obs');
routeParam = routeParam.substring(0, qIdx);
}
// Detect route param type: "id/123" for direct packet, short hex for hash, long hex for node
if (routeParam) {
if (routeParam.startsWith('id/')) {
directPacketId = routeParam.slice(3);
} else if (routeParam.length <= 16) {
filters.hash = routeParam;
directPacketHash = routeParam;
} else {
filters.node = routeParam;
}
}
app.innerHTML = `<div class="split-layout detail-collapsed">
<div class="panel-left" id="pktLeft"></div>
<div class="panel-right empty" id="pktRight" aria-live="polite">
<div class="panel-resize-handle" id="pktResizeHandle"></div>
${PANEL_CLOSE_HTML}
<span>Select a packet to view details</span>
</div>
</div>`;
initPanelResize();
document.getElementById('pktRight').addEventListener('click', function(e) {
if (e.target.closest('.panel-close-btn')) closeDetailPanel();
});
await loadObservers();
// Restore saved time window before first load
const fTW = document.getElementById('fTimeWindow');
const savedTW = localStorage.getItem('meshcore-time-window');
if (savedTW !== null && fTW) fTW.value = savedTW;
loadPackets();
// Auto-select packet detail when arriving via hash URL
if (directPacketHash) {
const h = directPacketHash;
const obsTarget = directObsId;
directPacketHash = null;
directObsId = null;
try {
const data = await api(`/packets/${h}`);
if (gen === initGeneration && data?.packet) {
if (obsTarget && data.observations) {
// Find the matching observation by its unique id
const obs = data.observations.find(o => String(o.id) === String(obsTarget));
if (obs) {
expandedHashes.add(h);
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
} else {
selectPacket(data.packet.id, h, data);
}
} else {
selectPacket(data.packet.id, h, data);
}
}
} catch {}
}
// Event delegation for data-action buttons
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.dataset.action === 'pkt-refresh') loadPackets();
else if (btn.dataset.action === 'pkt-byop') showBYOP();
else if (btn.dataset.action === 'pkt-pause') {
packetsPaused = !packetsPaused;
const pauseBtn = document.getElementById('pktPauseBtn');
if (pauseBtn) {
pauseBtn.textContent = packetsPaused ? '▶' : '⏸';
pauseBtn.title = packetsPaused ? 'Resume live updates' : 'Pause live updates';
pauseBtn.classList.toggle('active', packetsPaused);
}
if (!packetsPaused && pauseBuffer.length) {
const handler = wsHandler;
pauseBuffer.forEach(msg => { if (handler) handler(msg); });
pauseBuffer = [];
}
}
});
// If linked directly to a packet by ID, load its detail and filter list
if (directPacketId) {
const pktId = Number(directPacketId);
directPacketId = null;
try {
const data = await api(`/packets/${pktId}`);
if (gen !== initGeneration) return;
if (data.packet?.hash) {
filters.hash = data.packet.hash;
const hashInput = document.getElementById('fHash');
if (hashInput) hashInput.value = filters.hash;
await loadPackets();
}
// Show detail in sidebar
const panel = document.getElementById('pktRight');
if (panel) {
panel.classList.remove('empty');
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML;
const content = document.createElement('div');
panel.appendChild(content);
const pkt = data.packet;
try {
const hops = JSON.parse(pkt.path_json || '[]');
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
} catch {}
await renderDetail(content, data);
initPanelResize();
}
} catch {}
}
wsHandler = debouncedOnWS(function (msgs) {
if (packetsPaused) {
pauseBuffer.push(...msgs);
const btn = document.getElementById('pktPauseBtn');
if (btn) btn.textContent = '▶ ' + pauseBuffer.length;
return;
}
const newPkts = msgs
.filter(m => m.type === 'packet' && m.data?.packet)
.map(m => m.data.packet);
if (!newPkts.length) return;
// Check if new packets pass current filters
const filtered = newPkts.filter(p => {
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; }
if (filters.hash && p.hash !== filters.hash) return false;
if (RegionFilter.getRegionParam()) {
const selectedRegions = RegionFilter.getRegionParam().split(',');
const obs = observers.find(o => o.id === p.observer_id);
if (!obs || !selectedRegions.includes(obs.iata)) return false;
}
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
return true;
});
if (!filtered.length) return;
// Resolve any new hops, then update and re-render
const newHops = new Set();
for (const p of filtered) {
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
}
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
if (groupByHash) {
// Update existing groups or create new ones
for (const p of filtered) {
const h = p.hash;
const existing = hashIndex.get(h);
if (existing) {
existing.count = (existing.count || 1) + 1;
existing.observation_count = (existing.observation_count || 1) + 1;
existing.latest = p.timestamp > existing.latest ? p.timestamp : existing.latest;
// Track unique observers
if (p.observer_id && p.observer_id !== existing.observer_id) {
existing.observer_count = (existing.observer_count || 1) + 1;
}
// Don't update path — header always shows first observer's path
// Update decoded_json to latest
if (p.decoded_json) existing.decoded_json = p.decoded_json;
// Update expanded children if this group is expanded
if (expandedHashes.has(h) && existing._children) {
existing._children.unshift(p);
sortGroupChildren(existing);
}
} else {
// New group
const newGroup = {
hash: h,
count: 1,
observer_count: 1,
latest: p.timestamp,
observer_id: p.observer_id,
observer_name: p.observer_name,
path_json: p.path_json,
payload_type: p.payload_type,
raw_hex: p.raw_hex,
decoded_json: p.decoded_json,
};
packets.unshift(newGroup);
if (h) hashIndex.set(h, newGroup);
}
}
// Re-sort by latest DESC
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
} else {
// Flat mode: prepend
packets = filtered.concat(packets);
}
totalCount += filtered.length;
renderTableRows();
});
});
}
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
packets = [];
hashIndex = new Map(); selectedId = null;
filtersBuilt = false;
delete filters.node;
expandedHashes = new Set();
hopNameCache = {};
totalCount = 0;
observers = [];
directPacketId = null;
directPacketHash = null;
groupByHash = true;
filters = {};
regionMap = {};
}
async function loadObservers() {
try {
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
observers = data.observers || [];
} catch {}
}
async function loadPackets() {
try {
const params = new URLSearchParams();
const windowMin = Number(document.getElementById('fTimeWindow')?.value || 15);
if (windowMin > 0 && !filters.hash) {
const since = new Date(Date.now() - windowMin * 60000).toISOString();
params.set('since', since);
}
params.set('limit', '50000');
const regionParam = RegionFilter.getRegionParam();
if (regionParam) params.set('region', regionParam);
if (filters.hash) params.set('hash', filters.hash);
if (filters.node) params.set('node', filters.node);
params.set('groupByHash', 'true'); // always fetch grouped
const data = await api('/packets?' + params.toString());
packets = data.packets || [];
hashIndex = new Map();
for (const p of packets) { if (p.hash) hashIndex.set(p.hash, p); }
totalCount = data.total || packets.length;
// When ungrouped, fetch observations for all multi-obs packets and flatten
if (!groupByHash) {
const multiObs = packets.filter(p => (p.observation_count || p.count || 1) > 1);
await Promise.all(multiObs.map(async (p) => {
try {
const d = await api(`/packets/${p.hash}`);
if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true}));
} catch {}
}));
// Flatten: replace grouped packets with individual observations
const flat = [];
for (const p of packets) {
if (p._children && p._children.length > 1) {
for (const c of p._children) flat.push(c);
} else {
flat.push(p);
}
}
packets = flat;
totalCount = flat.length;
}
// Pre-resolve all path hops to node names
const allHops = new Set();
for (const p of packets) {
try { const path = JSON.parse(p.path_json || '[]'); path.forEach(h => allHops.add(h)); } catch {}
}
if (allHops.size) await resolveHops([...allHops]);
// Per-observer batch resolve for ambiguous hops (context-aware disambiguation)
const hopsByObserver = {};
for (const p of packets) {
if (!p.observer_id) continue;
try {
const path = JSON.parse(p.path_json || '[]');
const ambiguous = path.filter(h => hopNameCache[h]?.ambiguous);
if (ambiguous.length) {
if (!hopsByObserver[p.observer_id]) hopsByObserver[p.observer_id] = new Set();
ambiguous.forEach(h => hopsByObserver[p.observer_id].add(h));
}
} catch {}
}
// Ambiguous hops are already resolved by HopResolver client-side
// No need for per-observer server API calls
// Restore expanded group children
if (groupByHash && expandedHashes.size > 0) {
for (const hash of expandedHashes) {
const group = packets.find(p => p.hash === hash);
if (group) {
try {
const childData = await api(`/packets?hash=${hash}&limit=20`);
group._children = childData.packets || [];
sortGroupChildren(group);
} catch {}
} else {
// Group no longer in results — remove from expanded
expandedHashes.delete(hash);
}
}
}
renderLeft();
} catch (e) {
console.error('Failed to load packets:', e);
const tbody = document.getElementById('pktBody');
if (tbody) tbody.innerHTML = '<tr><td colspan="10" class="text-center" style="padding:24px;color:var(--error,#ef4444)"><div role="alert" aria-live="polite">Failed to load packets. Please try again.</div></td></tr>';
}
}
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 = `
<div class="page-header">
<h2>Latest Packets <span class="count">(${totalCount})</span></h2>
<div>
<button class="btn-icon" data-action="pkt-refresh" title="Refresh">🔄</button>
<button class="btn-icon" id="pktPauseBtn" data-action="pkt-pause" title="Pause live updates">⏸</button>
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
</div>
</div>
<div class="filter-group" style="flex:1;margin-bottom:8px">
<input type="text" id="packetFilterInput" class="packet-filter-input"
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
aria-label="Packet filter expression"
style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-family:var(--mono);font-size:13px;background:var(--input-bg);color:var(--text)">
<div id="packetFilterError" style="color:var(--status-red);font-size:11px;margin-top:2px;display:none"></div>
<div id="packetFilterCount" style="color:var(--text-muted);font-size:11px;margin-top:2px;display:none"></div>
</div>
<div class="filter-bar" id="pktFilters">
<button class="btn filter-toggle-btn" id="filterToggleBtn">Filters ▾</button>
<div class="filter-group">
<input type="text" placeholder="Packet hash…" id="fHash" aria-label="Filter by packet hash" title="Filter packets by hex hash prefix">
<div class="node-filter-wrap" style="position:relative">
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off" role="combobox" aria-expanded="false" aria-owns="fNodeDropdown" aria-activedescendant="" aria-autocomplete="list" title="Filter packets involving this node (sender or path)">
<div class="node-filter-dropdown hidden" id="fNodeDropdown" role="listbox"></div>
</div>
<div class="multi-select-wrap" id="observerFilterWrap">
<button class="multi-select-trigger" id="observerTrigger" title="Show only packets seen by selected observer stations">All Observers ▾</button>
<div class="multi-select-menu" id="observerMenu"></div>
</div>
<div id="packetsRegionFilter" class="region-filter-container" style="display:inline-block;vertical-align:middle"></div>
<div class="multi-select-wrap" id="typeFilterWrap">
<button class="multi-select-trigger" id="typeTrigger" title="Filter by packet type">All Types ▾</button>
<div class="multi-select-menu" id="typeMenu"></div>
</div>
</div>
<div class="filter-group">
<button class="btn ${groupByHash ? 'active' : ''}" id="fGroup" title="Collapse duplicate observations of the same packet into expandable groups">Group by Hash</button>
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes">★ My Nodes</button>
</div>
<div class="filter-group">
<select id="fTimeWindow" class="filter-select" aria-label="Time window filter">
<option value="15">Last 15 min</option>
<option value="30">Last 30 min</option>
<option value="60">Last 1 hour</option>
<option value="180">Last 3 hours</option>
<option value="360">Last 6 hours</option>
<option value="720">Last 12 hours</option>
<option value="1440">Last 24 hours</option>
<option value="0">All time</option>
</select>
</div>
<div class="filter-group">
<select id="fObsSort" aria-label="Observation sort order" title="Controls how observations are ordered within packet groups and which observation appears in the header row. Observer: Groups by observer station, earliest first. Path: Orders by hop count. Time: Orders by observation timestamp.">
<option value="observer">Sort: Observer</option>
<option value="path-asc">Sort: Path ↑ (shortest)</option>
<option value="path-desc">Sort: Path ↓ (longest)</option>
<option value="chrono-asc">Sort: Time ↑ (earliest)</option>
<option value="chrono-desc">Sort: Time ↓ (latest)</option>
</select>
<span class="sort-help" id="sortHelpIcon">ⓘ</span>
</div>
<div class="filter-group">
<div class="col-toggle-wrap">
<button class="col-toggle-btn" id="colToggleBtn" title="Show/hide table columns">Columns ▾</button>
<div class="col-toggle-menu" id="colToggleMenu"></div>
</div>
<button class="btn btn-icon${showHexHashes ? ' active' : ''}" id="hexHashToggle" title="Show raw hex hash prefixes instead of resolved node names in the path column">Hex Paths</button>
</div>
</div>
<table class="data-table" id="pktTable">
<thead><tr>
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
</tr></thead>
<tbody id="pktBody"></tbody>
</table>
`;
// Init shared RegionFilter component
RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true });
RegionFilter.onChange(function() { loadPackets(); });
// --- Packet Filter Language ---
(function() {
var pfInput = document.getElementById('packetFilterInput');
var pfError = document.getElementById('packetFilterError');
var pfCount = document.getElementById('packetFilterCount');
if (!pfInput || !window.PacketFilter) return;
var pfTimer = null;
pfInput.addEventListener('input', function() {
clearTimeout(pfTimer);
pfTimer = setTimeout(function() {
var expr = pfInput.value.trim();
if (!expr) {
pfInput.classList.remove('filter-error', 'filter-active');
pfError.style.display = 'none';
pfCount.style.display = 'none';
filters._packetFilter = null;
renderTableRows();
return;
}
var compiled = PacketFilter.compile(expr);
if (compiled.error) {
pfInput.classList.add('filter-error');
pfInput.classList.remove('filter-active');
pfError.textContent = compiled.error;
pfError.style.display = 'block';
pfCount.style.display = 'none';
filters._packetFilter = null;
renderTableRows();
} else {
pfInput.classList.remove('filter-error');
pfInput.classList.add('filter-active');
pfError.style.display = 'none';
filters._packetFilter = compiled.filter;
renderTableRows();
}
}, 300);
});
})();
// --- Observer multi-select ---
const obsMenu = document.getElementById('observerMenu');
const obsTrigger = document.getElementById('observerTrigger');
const selectedObservers = new Set(filters.observer ? filters.observer.split(',') : []);
function buildObserverMenu() {
const allChecked = selectedObservers.size === 0;
let html = `<label class="multi-select-item"><input type="checkbox" data-obs-id="__all__" ${allChecked ? 'checked' : ''}> All Observers</label>`;
for (const o of observers) {
const checked = selectedObservers.has(String(o.id)) ? 'checked' : '';
html += `<label class="multi-select-item"><input type="checkbox" data-obs-id="${o.id}" ${checked}> ${o.name || o.id}</label>`;
}
obsMenu.innerHTML = html;
}
function updateObsTrigger() {
if (selectedObservers.size === 0 || selectedObservers.size === observers.length) {
obsTrigger.textContent = 'All Observers ▾';
} else if (selectedObservers.size === 1) {
const id = [...selectedObservers][0];
const o = observers.find(x => String(x.id) === id);
obsTrigger.textContent = (o ? (o.name || o.id) : id) + ' ▾';
} else {
obsTrigger.textContent = selectedObservers.size + ' Observers ▾';
}
}
buildObserverMenu();
updateObsTrigger();
obsTrigger.addEventListener('click', (e) => { e.stopPropagation(); obsMenu.classList.toggle('open'); typeMenu.classList.remove('open'); });
obsMenu.addEventListener('change', (e) => {
const id = e.target.dataset.obsId;
if (id === '__all__') {
selectedObservers.clear();
} else {
if (e.target.checked) selectedObservers.add(id); else selectedObservers.delete(id);
}
filters.observer = selectedObservers.size > 0 ? [...selectedObservers].join(',') : undefined;
if (filters.observer) localStorage.setItem('meshcore-observer-filter', filters.observer); else localStorage.removeItem('meshcore-observer-filter');
buildObserverMenu();
updateObsTrigger();
renderTableRows();
});
// --- Type multi-select ---
const typeMenu = document.getElementById('typeMenu');
const typeTrigger = document.getElementById('typeTrigger');
const typeMap = {0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',7:'Anon Req',8:'Path',9:'Trace'};
const selectedTypes = new Set(filters.type ? String(filters.type).split(',') : []);
function buildTypeMenu() {
const allChecked = selectedTypes.size === 0;
let html = `<label class="multi-select-item"><input type="checkbox" data-type-id="__all__" ${allChecked ? 'checked' : ''}> All Types</label>`;
for (const [k, v] of Object.entries(typeMap)) {
const checked = selectedTypes.has(k) ? 'checked' : '';
html += `<label class="multi-select-item"><input type="checkbox" data-type-id="${k}" ${checked}> ${v}</label>`;
}
typeMenu.innerHTML = html;
}
function updateTypeTrigger() {
const total = Object.keys(typeMap).length;
if (selectedTypes.size === 0 || selectedTypes.size === total) {
typeTrigger.textContent = 'All Types ▾';
} else if (selectedTypes.size === 1) {
const k = [...selectedTypes][0];
typeTrigger.textContent = (typeMap[k] || k) + ' ▾';
} else {
typeTrigger.textContent = selectedTypes.size + ' Types ▾';
}
}
buildTypeMenu();
updateTypeTrigger();
typeTrigger.addEventListener('click', (e) => { e.stopPropagation(); typeMenu.classList.toggle('open'); obsMenu.classList.remove('open'); });
typeMenu.addEventListener('change', (e) => {
const id = e.target.dataset.typeId;
if (id === '__all__') {
selectedTypes.clear();
} else {
if (e.target.checked) selectedTypes.add(id); else selectedTypes.delete(id);
}
filters.type = selectedTypes.size > 0 ? [...selectedTypes].join(',') : undefined;
if (filters.type) localStorage.setItem('meshcore-type-filter', filters.type); else localStorage.removeItem('meshcore-type-filter');
buildTypeMenu();
updateTypeTrigger();
renderTableRows();
});
// Close multi-select menus on outside click
document.addEventListener('click', (e) => {
const obsWrap = document.getElementById('observerFilterWrap');
const typeWrap = document.getElementById('typeFilterWrap');
if (obsWrap && !obsWrap.contains(e.target)) { const m = obsWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); }
if (typeWrap && !typeWrap.contains(e.target)) { const m = typeWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); }
});
// Filter toggle button for mobile
document.getElementById('filterToggleBtn').addEventListener('click', function() {
const bar = document.getElementById('pktFilters');
bar.classList.toggle('filters-expanded');
this.textContent = bar.classList.contains('filters-expanded') ? 'Filters ▴' : 'Filters ▾';
});
// Filter event listeners
document.getElementById('fHash').value = filters.hash || '';
document.getElementById('fHash').addEventListener('input', debounce((e) => { filters.hash = e.target.value || undefined; loadPackets(); }, 300));
// Time window dropdown — restore from localStorage and bind change
const fTimeWindow = document.getElementById('fTimeWindow');
const savedWindow = localStorage.getItem('meshcore-time-window');
if (savedWindow !== null) fTimeWindow.value = savedWindow;
fTimeWindow.addEventListener('change', () => {
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
loadPackets();
});
document.getElementById('fGroup').addEventListener('click', () => { groupByHash = !groupByHash; loadPackets(); });
document.getElementById('fMyNodes').addEventListener('click', function () {
filters.myNodes = !filters.myNodes;
this.classList.toggle('active', filters.myNodes);
loadPackets();
});
// Observation sort dropdown
const obsSortSel = document.getElementById('fObsSort');
obsSortSel.value = obsSortMode;
const sortHelpEl = document.getElementById('sortHelpIcon');
if (sortHelpEl) {
const tip = document.createElement('span');
tip.className = 'sort-help-tip';
tip.textContent = "Sort controls how observations are ordered within packet groups and which observation appears in the header row.\n\nObserver — Groups by observer station, earliest first.\nPath \u2191 — Shortest paths first.\nPath \u2193 — Longest paths first.\nTime \u2191 — Earliest observation first.\nTime \u2193 — Most recent first.";
sortHelpEl.appendChild(tip);
}
obsSortSel.addEventListener('change', async function () {
obsSortMode = this.value;
localStorage.setItem('meshcore-obs-sort', obsSortMode);
// For non-observer sorts, fetch children for visible groups that don't have them yet
if (obsSortMode !== SORT_OBSERVER && groupByHash) {
const toFetch = packets.filter(p => p.hash && !p._children && (p.observation_count || 0) > 1);
await Promise.all(toFetch.map(async (p) => {
try {
const data = await api(`/packets/${p.hash}`);
if (data?.packet && data.observations) {
p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true}));
p._fetchedData = data;
}
} catch {}
}));
}
// Re-sort all groups with children
for (const p of packets) {
if (p._children) sortGroupChildren(p);
}
// Resolve any new hops from updated header paths
const newHops = new Set();
for (const p of packets) {
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
}
if (newHops.size) await resolveHops([...newHops]);
renderTableRows();
});
// Column visibility toggle (#71)
const COL_DEFS = [
{ key: 'region', label: 'Region' },
{ key: 'time', label: 'Time' },
{ key: 'hash', label: 'Hash' },
{ key: 'size', label: 'Size' },
{ key: 'type', label: 'Type' },
{ key: 'observer', label: 'Observer' },
{ key: 'path', label: 'Path' },
{ key: 'rpt', label: 'Rpt' },
{ key: 'details', label: 'Details' },
];
const isMobile = window.innerWidth <= 640;
const defaultHidden = isMobile ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region'];
let visibleCols;
try {
visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols'));
} catch {}
if (!visibleCols) visibleCols = COL_DEFS.map(c => c.key).filter(k => !defaultHidden.includes(k));
const colMenu = document.getElementById('colToggleMenu');
const pktTable = document.getElementById('pktTable');
function applyColVisibility() {
COL_DEFS.forEach(c => {
pktTable.classList.toggle('hide-col-' + c.key, !visibleCols.includes(c.key));
});
localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols));
}
colMenu.innerHTML = COL_DEFS.map(c =>
`<label><input type="checkbox" data-col="${c.key}" ${visibleCols.includes(c.key) ? 'checked' : ''}> ${c.label}</label>`
).join('');
colMenu.addEventListener('change', (e) => {
const cb = e.target;
const col = cb.dataset.col;
if (!col) return;
if (cb.checked) { if (!visibleCols.includes(col)) visibleCols.push(col); }
else { visibleCols = visibleCols.filter(k => k !== col); }
applyColVisibility();
});
document.getElementById('colToggleBtn').addEventListener('click', (e) => {
e.stopPropagation();
colMenu.classList.toggle('open');
});
document.addEventListener('click', () => colMenu.classList.remove('open'));
applyColVisibility();
document.getElementById('hexHashToggle').addEventListener('click', function () {
showHexHashes = !showHexHashes;
localStorage.setItem('meshcore-hex-hashes', showHexHashes);
this.classList.toggle('active', showHexHashes);
renderTableRows();
});
// Node name filter with autocomplete
const fNode = document.getElementById('fNode');
const fNodeDrop = document.getElementById('fNodeDropdown');
fNode.value = filters.nodeName || '';
let nodeActiveIdx = -1;
fNode.addEventListener('input', debounce(async (e) => {
const q = e.target.value.trim();
nodeActiveIdx = -1;
fNode.setAttribute('aria-activedescendant', '');
if (!q) {
fNodeDrop.classList.add('hidden');
fNode.setAttribute('aria-expanded', 'false');
if (filters.node) { filters.node = undefined; filters.nodeName = undefined; loadPackets(); }
return;
}
try {
const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q));
const data = await resp.json();
const nodes = data.nodes || [];
if (nodes.length === 0) { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); return; }
fNodeDrop.innerHTML = nodes.map((n, i) =>
`<div class="node-filter-option" id="fNodeOpt-${i}" role="option" data-key="${n.public_key}" data-name="${escapeHtml(n.name || n.public_key.slice(0,8))}">${escapeHtml(n.name || n.public_key.slice(0,8))} <span style="color:var(--muted);font-size:0.8em">${n.public_key.slice(0,8)}</span></div>`
).join('');
fNodeDrop.classList.remove('hidden');
fNode.setAttribute('aria-expanded', 'true');
fNodeDrop.querySelectorAll('.node-filter-option').forEach(opt => {
opt.addEventListener('click', () => {
selectNodeOption(opt);
});
});
} catch {}
}, 250));
function selectNodeOption(opt) {
filters.node = opt.dataset.key;
filters.nodeName = opt.dataset.name;
fNode.value = opt.dataset.name;
fNodeDrop.classList.add('hidden');
fNode.setAttribute('aria-expanded', 'false');
fNode.setAttribute('aria-activedescendant', '');
nodeActiveIdx = -1;
loadPackets();
}
fNode.addEventListener('keydown', (e) => {
const options = fNodeDrop.querySelectorAll('.node-filter-option');
if (!options.length || fNodeDrop.classList.contains('hidden')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
nodeActiveIdx = Math.min(nodeActiveIdx + 1, options.length - 1);
updateNodeActive(options);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
nodeActiveIdx = Math.max(nodeActiveIdx - 1, 0);
updateNodeActive(options);
} else if (e.key === 'Enter') {
e.preventDefault();
if (nodeActiveIdx >= 0 && options[nodeActiveIdx]) selectNodeOption(options[nodeActiveIdx]);
} else if (e.key === 'Escape') {
fNodeDrop.classList.add('hidden');
fNode.setAttribute('aria-expanded', 'false');
nodeActiveIdx = -1;
}
});
function updateNodeActive(options) {
options.forEach((o, i) => {
o.classList.toggle('node-filter-active', i === nodeActiveIdx);
o.setAttribute('aria-selected', i === nodeActiveIdx ? 'true' : 'false');
});
if (nodeActiveIdx >= 0 && options[nodeActiveIdx]) {
fNode.setAttribute('aria-activedescendant', options[nodeActiveIdx].id);
options[nodeActiveIdx].scrollIntoView({ block: 'nearest' });
}
}
fNode.addEventListener('blur', () => { setTimeout(() => { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); }, 200); });
// Delegated click/keyboard handler for table rows
const pktBody = document.getElementById('pktBody');
if (pktBody) {
const handler = (e) => {
// Let hop links navigate naturally without selecting the row
if (e.target.closest('[data-hop-link]')) return;
const row = e.target.closest('tr[data-action]');
if (!row) return;
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
const action = row.dataset.action;
const value = row.dataset.value;
if (action === 'select') {
const hash = row.dataset.hash;
if (hash) selectPacket(null, hash);
else selectPacket(Number(value));
}
else if (action === 'select-observation') {
const parentHash = row.dataset.parentHash;
const group = packets.find(p => p.hash === parentHash);
const child = group?._children?.find(c => String(c.id) === String(value));
if (child) {
const parentData = group._fetchedData;
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
}
}
else if (action === 'select-hash') pktSelectHash(value);
else if (action === 'toggle-select') { pktToggleGroup(value); pktSelectHash(value); }
};
pktBody.addEventListener('click', handler);
pktBody.addEventListener('keydown', handler);
}
// Escape to close packet detail panel
document.addEventListener('keydown', function pktEsc(e) {
if (e.key === 'Escape') {
closeDetailPanel();
}
});
renderTableRows();
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
}
async function renderTableRows() {
const tbody = document.getElementById('pktBody');
if (!tbody) return;
// Update dynamic parts of the header
const countEl = document.querySelector('#pktLeft .count');
const groupBtn = document.getElementById('fGroup');
if (groupBtn) groupBtn.classList.toggle('active', groupByHash);
// Filter to claimed/favorited nodes if toggle is on — use server-side multi-node lookup
let displayPackets = packets;
if (filters.myNodes) {
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
const myKeys = myNodes.map(n => n.pubkey).filter(Boolean);
const favs = getFavorites();
const allKeys = [...new Set([...myKeys, ...favs])];
if (allKeys.length > 0) {
try {
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
displayPackets = myData.packets || [];
} catch { displayPackets = []; }
} else {
displayPackets = [];
}
}
// Client-side type/observer filtering
if (filters.type) {
const types = filters.type.split(',').map(Number);
displayPackets = displayPackets.filter(p => types.includes(p.payload_type));
}
if (filters.observer) {
const obsIds = new Set(filters.observer.split(','));
displayPackets = displayPackets.filter(p => obsIds.has(p.observer_id));
}
// Packet Filter Language
const pfCount = document.getElementById('packetFilterCount');
if (filters._packetFilter) {
const beforeCount = displayPackets.length;
displayPackets = displayPackets.filter(filters._packetFilter);
if (pfCount) {
pfCount.textContent = 'Showing ' + displayPackets.length.toLocaleString() + ' of ' + beforeCount.toLocaleString() + ' packets';
pfCount.style.display = 'block';
}
} else if (pfCount) {
pfCount.style.display = 'none';
}
if (countEl) countEl.textContent = `(${displayPackets.length})`;
if (!displayPackets.length) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted" style="padding:24px">' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + '</td></tr>';
return;
}
if (groupByHash) {
let html = '';
for (const p of displayPackets) {
const isExpanded = expandedHashes.has(p.hash);
// When observer filter is active, use first matching child's data for header
let headerObserverId = p.observer_id;
let headerPathJson = p.path_json;
if (filters.observer && p._children?.length) {
const obsIds = new Set(filters.observer.split(','));
const match = p._children.find(c => obsIds.has(String(c.observer_id)));
if (match) {
headerObserverId = match.observer_id;
headerPathJson = match.path_json;
}
}
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
let groupPath = [];
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
const groupPathStr = renderPath(groupPath, headerObserverId);
const groupTypeName = payloadTypeName(p.payload_type);
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const isSingle = p.count <= 1;
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${timeAgo(p.latest)}</td>
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
</tr>`;
// Child rows (loaded async when expanded)
if (isExpanded && p._children) {
let visibleChildren = p._children;
// Filter children by selected observers
if (filters.observer) {
const obsSet = new Set(filters.observer.split(','));
visibleChildren = visibleChildren.filter(c => obsSet.has(String(c.observer_id)));
}
for (const c of visibleChildren) {
const typeName = payloadTypeName(c.payload_type);
const typeClass = payloadTypeColor(c.payload_type);
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
const childPathStr = renderPath(childPath, c.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
<td class="col-time">${timeAgo(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
</tr>`;
}
}
}
tbody.innerHTML = html;
return;
}
tbody.innerHTML = displayPackets.map(p => {
let decoded, pathHops = [];
try { decoded = JSON.parse(p.decoded_json); } catch {}
try { pathHops = JSON.parse(p.path_json || '[]'); } catch {}
const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : '';
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
<td class="col-time">${timeAgo(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${detail}</td>
</tr>`;
}).join('');
}
function getDetailPreview(decoded) {
if (!decoded) return '';
// Channel messages (GRP_TXT) — show channel name and message text
if (decoded.type === 'CHAN' && decoded.text) {
const ch = decoded.channel ? `<span class="chan-tag">${escapeHtml(decoded.channel)}</span> ` : '';
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} <a href="#/nodes/${encodeURIComponent(decoded.pubKey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(decoded.name)}</a>`;
}
// 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} <span class="muted">(${statusLabel})</span>`;
}
// 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 = '<div class="mobile-sheet-handle"></div><button class="mobile-sheet-close" id="mobileSheetClose">✕</button><div class="mobile-sheet-content"></div>';
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 = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
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 = '<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML + '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
initPanelResize();
}
try {
const data = prefetchedData || await api(hash ? `/packets/${hash}` : `/packets/${id}`);
// Resolve path hops for detail view
const pkt = data.packet;
try {
const hops = JSON.parse(pkt.path_json || '[]');
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
} catch {}
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML;
const content = document.createElement('div');
panel.appendChild(content);
await renderDetail(content, data);
if (!isMobileNow) initPanelResize();
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
}
}
async function renderDetail(panel, data) {
const pkt = data.packet;
const breakdown = data.breakdown || {};
const ranges = breakdown.ranges || [];
let decoded;
try { decoded = JSON.parse(pkt.decoded_json); } catch { decoded = {}; }
let pathHops;
try { pathHops = JSON.parse(pkt.path_json || '[]'); } catch { pathHops = []; }
// Resolve sender GPS — from packet directly, or from known node in DB
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
let senderLon = decoded.lon != null ? decoded.lon : (decoded.longitude || null);
if (senderLat == null) {
// Try to find sender node GPS from DB
const senderKey = decoded.pubKey || decoded.srcPubKey;
const senderName = decoded.sender || decoded.name;
try {
if (senderKey) {
const nd = await api(`/nodes/${senderKey}`, { ttl: 30000 }).catch(() => null);
if (nd?.node?.lat && nd.node.lon) { senderLat = nd.node.lat; senderLon = nd.node.lon; }
}
if (senderLat == null && senderName) {
const sd = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
const match = sd?.nodes?.[0];
if (match?.lat && match.lon) { senderLat = match.lat; senderLon = match.lon; }
}
} catch {}
}
// Re-resolve hops using client-side HopResolver with sender GPS context
if (pathHops.length) {
try {
await ensureHopResolver();
const resolved = HopResolver.resolve(pathHops);
if (resolved) {
for (const [k, v] of Object.entries(resolved)) {
hopNameCache[k] = v;
if (pkt.observer_id) hopNameCache[k + ':' + pkt.observer_id] = v;
}
}
} catch {}
}
// Parse hash size from path byte
const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN;
const hashSize = isNaN(rawPathByte) ? null : ((rawPathByte >> 6) + 1);
const size = pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0;
const typeName = payloadTypeName(pkt.payload_type);
const snr = pkt.snr ?? decoded.SNR ?? decoded.snr ?? null;
const rssi = pkt.rssi ?? decoded.RSSI ?? decoded.rssi ?? null;
const hasRawHex = !!pkt.raw_hex;
// Build message preview
let messageHtml = '';
if (decoded.text) {
const chLabel = decoded.channel || (decoded.channel_idx != null ? `Ch ${decoded.channel_idx}` : null) || (decoded.channelHash != null ? `Ch 0x${decoded.channelHash.toString(16)}` : '');
const hopLabel = decoded.path_len != null ? `${decoded.path_len} hops` : '';
const snrLabel = snr != null ? `SNR ${snr} dB` : '';
const meta = [chLabel, hopLabel, snrLabel].filter(Boolean).join(' · ');
messageHtml = `<div class="detail-message" style="padding:12px;margin:8px 0;background:var(--card-bg);border-radius:8px;border-left:3px solid var(--accent)">
<div style="font-size:1.1em">${escapeHtml(decoded.text)}</div>
${meta ? `<div style="font-size:0.85em;color:var(--muted);margin-top:4px">${meta}</div>` : ''}
</div>`;
} 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 = `<div class="detail-message" style="padding:12px;margin:8px 0;background:var(--card-bg);border-radius:8px;border-left:3px solid var(--warning, #f0ad4e)">
<div style="font-size:1.1em">🔒 Channel Hash: 0x${hashHex} <span style="color:var(--muted)">(${statusLabel})</span></div>
</div>`;
}
const observations = data.observations || [];
const obsCount = data.observation_count || observations.length || 1;
const uniqueObservers = new Set(observations.map(o => o.observer_id)).size;
// Propagation time: spread between first and last observation
let propagationHtml = '—';
if (observations.length >= 2) {
const times = observations.map(o => new Date(o.timestamp).getTime()).filter(t => !isNaN(t));
if (times.length >= 2) {
const first = Math.min(...times);
const last = Math.max(...times);
const spread = last - first;
if (spread < 1000) {
propagationHtml = `${spread}ms`;
} else if (spread < 60000) {
propagationHtml = `${(spread / 1000).toFixed(1)}s`;
} else {
propagationHtml = `${(spread / 60000).toFixed(1)}m`;
}
propagationHtml += ` <span style="color:var(--text-muted);font-size:0.85em">(${obsCount} obs × ${uniqueObservers} observers)</span>`;
}
}
// 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 += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
} 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 += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
} 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 += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
}
}
} catch {}
}
}
panel.innerHTML = `
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}</div>
${messageHtml}
<dl class="detail-meta">
<dt>Observer</dt><dd>${obsName(pkt.observer_id)}</dd>
<dt>Location</dt><dd>${locationHtml}</dd>
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
<dt>Timestamp</dt><dd>${pkt.timestamp}</dd>
<dt>Propagation</dt><dd>${propagationHtml}</dd>
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}</dd>
</dl>
<div class="detail-actions">
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
${pathHops.length ? `<button class="detail-map-link" id="viewRouteBtn">🗺️ View route on map</button>` : ''}
${pkt.hash ? `<a href="#/traces/${pkt.hash}" class="detail-map-link" style="text-decoration:none">🔍 Trace</a>` : ''}
<button class="replay-live-btn" title="Replay this packet on the live map">▶ Replay</button>
</div>
${hasRawHex ? `<div class="hex-legend">${buildHexLegend(ranges)}</div>
<div class="hex-dump">${createColoredHexDump(pkt.raw_hex, ranges)}</div>` : ''}
${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
`;
// Wire up copy link button
const copyLinkBtn = panel.querySelector('.copy-link-btn');
if (copyLinkBtn) {
copyLinkBtn.addEventListener('click', () => {
const pktHash = copyLinkBtn.dataset.packetHash;
const obsParam = selectedObservationId ? `?obs=${selectedObservationId}` : '';
const url = pktHash ? `${location.origin}/#/packets/${pktHash}${obsParam}` : `${location.origin}/#/packets/${copyLinkBtn.dataset.packetId}${obsParam}`;
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) {
let oPath;
try { oPath = JSON.parse(o.path_json || '[]'); } catch { oPath = pathHops; }
let oDec;
try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; }
replayPackets.push({
id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex,
_ts: new Date(o.timestamp).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: oDec, path: { hops: oPath } },
snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id)
});
}
} else {
replayPackets.push({
id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex,
_ts: new Date(pkt.timestamp).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } },
snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id)
});
}
sessionStorage.setItem('replay-packet', JSON.stringify(replayPackets));
window.location.hash = '#/live';
});
}
// Wire up view route on map button
const routeBtn = document.getElementById('viewRouteBtn');
if (routeBtn && pathHops.length) {
routeBtn.addEventListener('click', async () => {
try {
// Anchor disambiguation from sender's location if known (e.g. ADVERT lat/lon)
const senderLat = decoded.lat || decoded.latitude;
const senderLon = decoded.lon || decoded.longitude;
// Resolve observer position for backward-pass anchor
let obsLat = null, obsLon = null;
const obsId = obsName(pkt.observer_id);
if (obsId && HopResolver.ready()) {
// Try to find observer in nodes list by name — best effort
}
await ensureHopResolver();
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
// Pass full pubkeys (client-disambiguated) to map, falling back to short prefix
const resolvedKeys = pathHops.map(h => {
const r = data.resolved?.[h];
return r?.pubkey || h;
});
// Build origin info for the sender node
const origin = {};
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
if (senderLat != null && senderLon != null) { origin.lat = senderLat; origin.lon = senderLon; }
sessionStorage.setItem('map-route-hops', JSON.stringify({
origin: origin,
hops: resolvedKeys
}));
window.location.hash = '#/map?route=1';
} catch {
window.location.hash = '#/map';
}
});
}
}
function buildDecodedTable(decoded) {
let rows = '';
for (const [k, v] of Object.entries(decoded)) {
if (v === null || v === undefined) continue;
rows += `<tr><td style="font-weight:600;padding:4px 8px">${escapeHtml(k)}</td><td style="padding:4px 8px">${escapeHtml(String(v))}</td></tr>`;
}
return rows ? `<table class="detail-decoded" style="width:100%;border-collapse:collapse;margin-top:8px">${rows}</table>` : '';
}
function buildFieldTable(pkt, decoded, pathHops, ranges) {
const buf = pkt.raw_hex || '';
const size = Math.floor(buf.length / 2);
let rows = '';
// Header section
rows += sectionRow('Header');
rows += fieldRow(0, 'Header Byte', '0x' + (buf.slice(0, 2) || '??'), `Route: ${routeTypeName(pkt.route_type)}, Payload: ${payloadTypeName(pkt.payload_type)}`);
const pathByte0 = parseInt(buf.slice(2, 4), 16);
const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1);
const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F);
rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`);
// Transport codes
let off = 2;
if (pkt.route_type === 0 || pkt.route_type === 3) {
rows += sectionRow('Transport Codes');
rows += fieldRow(off, 'Next Hop', buf.slice(off * 2, (off + 2) * 2), '');
rows += fieldRow(off + 2, 'Last Hop', buf.slice((off + 2) * 2, (off + 4) * 2), '');
off += 4;
}
// Path
if (pathHops.length > 0) {
rows += sectionRow('Path (' + pathHops.length + ' hops)');
const pathByte = parseInt(buf.slice(2, 4), 16);
const hashSize = (pathByte >> 6) + 1;
for (let i = 0; i < pathHops.length; i++) {
const hopHtml = HopDisplay.renderHop(pathHops[i], hopNameCache[pathHops[i]]);
const label = `Hop ${i}${hopHtml}`;
rows += fieldRow(off + i * hashSize, label, pathHops[i], '');
}
off += hashSize * pathHops.length;
}
// Payload
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type));
if (decoded.type === 'ADVERT') {
rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1));
rows += fieldRow(off, 'Public Key (32B)', truncate(decoded.pubKey || '', 24), '');
rows += fieldRow(off + 32, 'Timestamp (4B)', decoded.timestampISO || '', 'Unix: ' + (decoded.timestamp || ''));
rows += fieldRow(off + 36, 'Signature (64B)', truncate(decoded.signature || '', 24), '');
if (decoded.flags) {
const _typeLabels = {1:'Companion',2:'Repeater',3:'Room Server',4:'Sensor'};
const _typeName = _typeLabels[decoded.flags.type] || ('Unknown(' + decoded.flags.type + ')');
const _boolFlags = [decoded.flags.hasLocation && 'location', decoded.flags.hasName && 'name'].filter(Boolean);
const _flagDesc = _typeName + (_boolFlags.length ? ' + ' + _boolFlags.join(', ') : '');
rows += fieldRow(off + 100, 'App Flags', '0x' + (decoded.flags.raw?.toString(16).padStart(2,'0') || '??'), _flagDesc);
let fOff = off + 101;
if (decoded.flags.hasLocation) {
rows += fieldRow(fOff, 'Latitude', decoded.lat?.toFixed(6) || '', '');
rows += fieldRow(fOff + 4, 'Longitude', decoded.lon?.toFixed(6) || '', '');
fOff += 8;
}
if (decoded.flags.hasName) {
rows += fieldRow(fOff, 'Node Name', decoded.pubKey ? `<a href="#/nodes/${encodeURIComponent(decoded.pubKey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(decoded.name || '')}</a>` : 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, 'Dest Hash (6B)', decoded.destHash || '', '');
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
rows += fieldRow(off + 12, 'Extra (6B)', decoded.extraHash || '', '');
} else if (decoded.destHash !== undefined) {
rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
rows += fieldRow(off + 12, 'MAC (4B)', decoded.mac || '', '');
rows += fieldRow(off + 16, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
} else {
rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), '');
}
return `<table class="field-table">
<thead><tr><th scope="col">Offset</th><th scope="col">Field</th><th scope="col">Value</th><th scope="col">Description</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
function sectionRow(label) {
return `<tr class="section-row"><td colspan="4">${label}</td></tr>`;
}
function fieldRow(offset, name, value, desc) {
return `<tr><td class="mono">${offset}</td><td>${name}</td><td class="mono">${value}</td><td class="text-muted">${desc || ''}</td></tr>`;
}
// BYOP modal — decode only, no DB injection
function showBYOP() {
const triggerBtn = document.querySelector('[data-action="pkt-byop"]');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = '<div class="modal byop-modal" role="dialog" aria-label="Decode a Packet" aria-modal="true">'
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog">✕</button></div>'
+ '<p class="text-muted" style="margin:0 0 12px;font-size:.85rem">Paste raw hex bytes from your radio or MQTT feed:</p>'
+ '<textarea id="byopHex" class="byop-input" aria-label="Packet hex data" placeholder="e.g. 15C31A8D4674FEAE37..." spellcheck="false"></textarea>'
+ '<button class="btn-primary byop-go" id="byopDecode" style="width:100%;margin:8px 0">Decode</button>'
+ '<div id="byopResult" role="status" aria-live="polite"></div>'
+ '</div>';
document.body.appendChild(overlay);
const modal = overlay.querySelector('.byop-modal');
const close = () => { overlay.remove(); if (triggerBtn) triggerBtn.focus(); };
overlay.querySelector('.byop-x').onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
// Focus trap
function getFocusable() {
return modal.querySelectorAll('textarea, button, input, [tabindex]:not([tabindex="-1"])');
}
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Tab') {
const focusable = getFocusable();
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
});
const textarea = overlay.querySelector('#byopHex');
textarea.focus();
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
doDecode();
}
});
overlay.querySelector('#byopDecode').onclick = doDecode;
async function doDecode() {
const hex = textarea.value.trim().replace(/[\s\n]/g, '');
const result = document.getElementById('byopResult');
if (!hex) { result.innerHTML = '<p class="text-muted">Enter hex data</p>'; return; }
if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = '<p class="byop-err" role="alert">Invalid hex — only 0-9 and A-F allowed</p>'; return; }
result.innerHTML = '<p class="text-muted">Decoding...</p>';
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 = '<p class="byop-err" role="alert">❌ ' + e.message + '</p>';
}
}
}
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 = '<div class="byop-decoded">';
// Header section
html += '<div class="byop-section">'
+ '<div class="byop-section-title">Header</div>'
+ '<div class="byop-kv">'
+ kv('Route Type', routeTypeName(h.routeType))
+ kv('Payload Type', payloadTypeName(h.payloadType))
+ kv('Version', h.payloadVersion)
+ kv('Size', size + ' bytes')
+ '</div></div>';
// Path section
if (path.hops && path.hops.length) {
html += '<div class="byop-section">'
+ '<div class="byop-section-title">Path (' + path.hops.length + ' hops)</div>'
+ '<div class="byop-path">' + path.hops.map(function(hop) { return '<span class="hop">' + hop + '</span>'; }).join('<span class="arrow">→</span>') + '</div>'
+ '</div>';
}
// Payload section
html += '<div class="byop-section">'
+ '<div class="byop-section-title">Payload — ' + payloadTypeName(h.payloadType) + '</div>'
+ '<div class="byop-kv">';
for (const [k, v] of Object.entries(p)) {
if (v === null || v === undefined) continue;
if (typeof v === 'object') {
html += kv(k, '<pre class="byop-pre">' + JSON.stringify(v, null, 2) + '</pre>');
} else {
html += kv(k, String(v));
}
}
html += '</div></div>';
// Raw hex
html += '<div class="byop-section">'
+ '<div class="byop-section-title">Raw Hex</div>'
+ '<div class="byop-hex mono">' + hex.toUpperCase().match(/.{1,2}/g).join(' ') + '</div>'
+ '</div>';
html += '</div>';
return html;
}
function kv(key, val) {
return '<div class="byop-row"><span class="byop-key">' + key + '</span><span class="byop-val">' + val + '</span></div>';
}
// Load regions from config API
(async () => {
try {
regionMap = await api('/config/regions', { ttl: 3600 });
} catch {}
})();
// Observation sort modes
const SORT_OBSERVER = 'observer';
const SORT_PATH_ASC = 'path-asc';
const SORT_PATH_DESC = 'path-desc';
const SORT_CHRONO_ASC = 'chrono-asc';
const SORT_CHRONO_DESC = 'chrono-desc';
let obsSortMode = localStorage.getItem('meshcore-obs-sort') || SORT_OBSERVER;
function getPathHopCount(c) {
try { return JSON.parse(c.path_json || '[]').length; } catch { return 0; }
}
function sortGroupChildren(group) {
if (!group || !group._children || !group._children.length) return;
const mode = obsSortMode;
if (mode === SORT_CHRONO_ASC || mode === SORT_CHRONO_DESC) {
const dir = mode === SORT_CHRONO_ASC ? 1 : -1;
group._children.sort((a, b) => {
const tA = a.timestamp || '', tB = b.timestamp || '';
return tA < tB ? -dir : tA > tB ? dir : 0;
});
} else if (mode === SORT_PATH_ASC || mode === SORT_PATH_DESC) {
const dir = mode === SORT_PATH_ASC ? 1 : -1;
group._children.sort((a, b) => {
const lenA = getPathHopCount(a), lenB = getPathHopCount(b);
if (lenA !== lenB) return (lenA - lenB) * dir;
const oA = (a.observer_name || '').toLowerCase(), oB = (b.observer_name || '').toLowerCase();
return oA < oB ? -1 : oA > oB ? 1 : 0;
});
} else {
// Default: group by observer, earliest-observer first, then ascending time within each
const earliest = {};
for (const c of group._children) {
const obs = c.observer_name || c.observer || '';
const t = c.timestamp || c.rx_at || c.created_at || '';
if (!earliest[obs] || t < earliest[obs]) earliest[obs] = t;
}
group._children.sort((a, b) => {
const oA = a.observer_name || a.observer || '', oB = b.observer_name || b.observer || '';
const eA = earliest[oA] || '', eB = earliest[oB] || '';
if (eA !== eB) return eA < eB ? -1 : 1;
if (oA !== oB) return oA < oB ? -1 : 1;
const tA = a.timestamp || a.rx_at || '', tB = b.timestamp || b.rx_at || '';
return tA < tB ? -1 : tA > tB ? 1 : 0;
});
}
// Update header row to match first sorted child
const first = group._children[0];
if (first) {
group.observer_id = first.observer_id;
group.observer_name = first.observer_name;
group.snr = first.snr;
group.rssi = first.rssi;
group.path_json = first.path_json;
group.direction = first.direction;
}
}
// Global handlers
async function pktToggleGroup(hash) {
if (expandedHashes.has(hash)) {
expandedHashes.delete(hash);
renderTableRows();
return;
}
// Single fetch — gets packet + observations + path + breakdown
try {
const data = await api(`/packets/${hash}`);
const pkt = data.packet;
if (!pkt) return;
const group = packets.find(p => p.hash === hash);
if (group && data.observations) {
group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true}));
group._fetchedData = data;
// Sort children based on current sort mode
sortGroupChildren(group);
}
// Resolve any new hops from children
const childHops = new Set();
for (const c of (group?._children || [])) {
try { JSON.parse(c.path_json || '[]').forEach(h => childHops.add(h)); } catch {}
}
const newHops = [...childHops].filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
expandedHashes.add(hash);
renderTableRows();
// Also open detail panel — no extra fetch needed
selectPacket(pkt.id, hash, data);
} catch {}
}
async function pktSelectHash(hash) {
// When grouped, select packet — reuse cached detail endpoint
try {
const data = await api(`/packets/${hash}`);
if (data?.packet) selectPacket(data.packet.id, hash, data);
} catch {}
}
let _themeRefreshHandler = null;
registerPage('packets', {
init: function(app, routeParam) {
_themeRefreshHandler = () => { if (typeof renderTableRows === 'function') renderTableRows(); };
window.addEventListener('theme-refresh', _themeRefreshHandler);
return init(app, routeParam);
},
destroy: function() {
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
return destroy();
}
});
// Standalone packet detail page: #/packet/123 or #/packet/HASH
registerPage('packet-detail', {
init: async (app, routeParam) => {
const param = routeParam;
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet…</div></div>`;
try {
await loadObservers();
const data = await api(`/packets/${param}`);
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet ${param} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
const hops = [];
try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {}
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
const container = document.createElement('div');
container.style.cssText = 'max-width:800px;margin:0 auto;padding:20px';
container.innerHTML = `<div style="margin-bottom:16px"><a href="#/packets" style="color:var(--accent);text-decoration:none">← Back to packets</a></div>`;
const detail = document.createElement('div');
container.appendChild(detail);
await renderDetail(detail, data);
app.innerHTML = '';
app.appendChild(container);
} catch (e) {
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Error</h2><p>${e.message}</p><a href="#/packets">← Back to packets</a></div>`;
}
},
destroy: () => {}
});
})();