/* === CoreScope — compare.js === */ /* Observer packet comparison — Fixes #129 */ 'use strict'; /** * Compare two sets of packet hashes using Set operations. * Returns { onlyA, onlyB, both } as arrays of hashes. * O(n) via Set lookups — no nested loops. */ function comparePacketSets(hashesA, hashesB) { var setA = hashesA instanceof Set ? hashesA : new Set(hashesA || []); var setB = hashesB instanceof Set ? hashesB : new Set(hashesB || []); var onlyA = []; var onlyB = []; var both = []; setA.forEach(function (h) { if (setB.has(h)) both.push(h); else onlyA.push(h); }); setB.forEach(function (h) { if (!setA.has(h)) onlyB.push(h); }); return { onlyA: onlyA, onlyB: onlyB, both: both }; } /** * Filter packets by route type. * mode: 'all' | 'flood' | 'direct' * Flood = route_type 0 (TransportFlood) or 1 (Flood) * Direct = route_type 2 (Direct) or 3 (TransportDirect) */ function filterPacketsByRoute(packets, mode) { if (!packets || mode === 'all') return packets || []; if (mode === 'flood') { return packets.filter(function (p) { return p.route_type === 0 || p.route_type === 1; }); } if (mode === 'direct') { return packets.filter(function (p) { return p.route_type === 2 || p.route_type === 3; }); } return packets; } /** * Compute asymmetric overlap statistics between two observer packet sets. * Given a comparePacketSets() result, returns: * - totalA / totalB: unique packet count for each observer * - shared: packets seen by both * - onlyA / onlyB: exclusive packet counts * - aSeesOfB: percentage of B's packets that A also saw (rounded to 0.1%) * - bSeesOfA: percentage of A's packets that B also saw (rounded to 0.1%) * Returns 0% (not NaN) when a denominator is zero. */ function computeOverlapStats(cmp) { var onlyA = (cmp && cmp.onlyA && cmp.onlyA.length) || 0; var onlyB = (cmp && cmp.onlyB && cmp.onlyB.length) || 0; var shared = (cmp && cmp.both && cmp.both.length) || 0; var totalA = onlyA + shared; var totalB = onlyB + shared; var aSeesOfB = totalB > 0 ? Math.round((shared / totalB) * 1000) / 10 : 0; var bSeesOfA = totalA > 0 ? Math.round((shared / totalA) * 1000) / 10 : 0; return { totalA: totalA, totalB: totalB, shared: shared, onlyA: onlyA, onlyB: onlyB, aSeesOfB: aSeesOfB, bSeesOfA: bSeesOfA, }; } // Expose for testing if (typeof window !== 'undefined') { window.comparePacketSets = comparePacketSets; window.filterPacketsByRoute = filterPacketsByRoute; window.computeOverlapStats = computeOverlapStats; } (function () { var PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' }; var MAX_PACKETS = 10000; var observers = []; var selA = null; var selB = null; var comparisonResult = null; var packetsA = []; var packetsB = []; var currentView = 'summary'; var routeFilter = 'all'; function init(app, routeParam) { // Parse preselected observers from URL: #/compare?a=ID1&b=ID2 var hashParams = location.hash.split('?')[1] || ''; var params = new URLSearchParams(hashParams); selA = params.get('a') || null; selB = params.get('b') || null; comparisonResult = null; packetsA = []; packetsB = []; currentView = 'summary'; routeFilter = 'all'; app.innerHTML = '
' + '' + '
Loading observers\u2026
' + '
' + '
'; // #209 — Keyboard accessibility for compare table rows app.addEventListener('keydown', function (e) { var row = e.target.closest('tr[data-action="navigate"]'); if (!row) return; if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); location.hash = row.dataset.value; }); loadObservers(); } function destroy() { observers = []; selA = null; selB = null; comparisonResult = null; packetsA = []; packetsB = []; routeFilter = 'all'; } async function loadObservers() { try { var data = await api('/observers', { ttl: CLIENT_TTL.observers }); observers = (data.observers || []).sort(function (a, b) { return (a.name || a.id).localeCompare(b.name || b.id); }); renderControls(); if (selA && selB) runComparison(); } catch (e) { document.getElementById('compareControls').innerHTML = '
Error loading observers: ' + escapeHtml(e.message) + '
'; } } function renderControls() { var el = document.getElementById('compareControls'); if (!el) return; var optionsHtml = '' + observers.map(function (o) { var label = escapeHtml(o.name || o.id); var region = o.iata ? ' (' + escapeHtml(o.iata) + ')' : ''; return ''; }).join(''); el.innerHTML = '
' + '
' + '' + '' + '
' + 'vs' + '
' + '' + '' + '
' + '' + '
' + '' + '' + '
' + '
'; var ddA = document.getElementById('compareObsA'); var ddB = document.getElementById('compareObsB'); var btn = document.getElementById('compareBtn'); if (selA) ddA.value = selA; if (selB) ddB.value = selB; var ddRoute = document.getElementById('compareRouteFilter'); ddRoute.value = routeFilter; ddRoute.addEventListener('change', function () { routeFilter = ddRoute.value; if (comparisonResult) runComparison(); }); function updateBtn() { selA = ddA.value || null; selB = ddB.value || null; btn.disabled = !selA || !selB || selA === selB; } ddA.addEventListener('change', updateBtn); ddB.addEventListener('change', updateBtn); btn.addEventListener('click', function () { runComparison(); }); updateBtn(); } function sinceISO(hours) { return new Date(Date.now() - hours * 3600000).toISOString(); } async function runComparison() { if (!selA || !selB || selA === selB) return; var content = document.getElementById('compareContent'); if (!content) return; content.innerHTML = '
Fetching packets\u2026
'; // Update URL for shareability var base = '#/compare?a=' + encodeURIComponent(selA) + '&b=' + encodeURIComponent(selB); if (location.hash.split('?')[0] === '#/compare') { history.replaceState(null, '', base); } try { var since24h = sinceISO(24); var results = await Promise.all([ api('/packets?observer=' + encodeURIComponent(selA) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(since24h)), api('/packets?observer=' + encodeURIComponent(selB) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(since24h)) ]); packetsA = results[0].packets || []; packetsB = results[1].packets || []; // Apply flood/direct filter (#928) var filteredA = filterPacketsByRoute(packetsA, routeFilter); var filteredB = filterPacketsByRoute(packetsB, routeFilter); var hashesA = new Set(filteredA.map(function (p) { return p.hash; })); var hashesB = new Set(filteredB.map(function (p) { return p.hash; })); comparisonResult = comparePacketSets(hashesA, hashesB); // Build hash→packet lookups for detail rendering comparisonResult.packetMapA = new Map(); comparisonResult.packetMapB = new Map(); filteredA.forEach(function (p) { comparisonResult.packetMapA.set(p.hash, p); }); filteredB.forEach(function (p) { comparisonResult.packetMapB.set(p.hash, p); }); currentView = 'summary'; renderComparison(); } catch (e) { content.innerHTML = '
Error: ' + escapeHtml(e.message) + '
'; } } function obsName(id) { for (var i = 0; i < observers.length; i++) { if (observers[i].id === id) return observers[i].name || id; } return id ? id.substring(0, 12) : 'Unknown'; } function renderComparison() { var content = document.getElementById('compareContent'); if (!content || !comparisonResult) return; var r = comparisonResult; var nameA = escapeHtml(obsName(selA)); var nameB = escapeHtml(obsName(selB)); var total = r.onlyA.length + r.onlyB.length + r.both.length; var pctBoth = total > 0 ? Math.round(r.both.length / total * 100) : 0; var pctA = total > 0 ? Math.round(r.onlyA.length / total * 100) : 0; var pctB = total > 0 ? Math.round(r.onlyB.length / total * 100) : 0; // Type breakdown for "both" packets var typeBreakdown = {}; r.both.forEach(function (h) { var p = r.packetMapA.get(h) || r.packetMapB.get(h); if (p) { var t = p.payload_type; typeBreakdown[t] = (typeBreakdown[t] || 0) + 1; } }); var typeHtml = Object.keys(typeBreakdown).map(function (t) { return '' + escapeHtml(PAYLOAD_LABELS[t] || 'Type ' + t) + ': ' + typeBreakdown[t] + ''; }).join(' '); content.innerHTML = '
' + // Summary cards '
' + '
' + '
' + r.both.length.toLocaleString() + '
' + '
Seen by both
' + '
' + pctBoth + '%
' + '
' + '
' + '
' + r.onlyA.length.toLocaleString() + '
' + '
Only ' + nameA + '
' + '
' + pctA + '%
' + '
' + '
' + '
' + r.onlyB.length.toLocaleString() + '
' + '
Only ' + nameB + '
' + '
' + pctB + '%
' + '
' + '
' + // Visual bar '
' + '
' + (pctA > 0 ? '
' : '') + (pctBoth > 0 ? '
' : '') + (pctB > 0 ? '
' : '') + '
' + '
' + ' ' + nameA + ' only' + ' Both' + ' ' + nameB + ' only' + '
' + '
' + // Type breakdown for shared packets (typeHtml ? '
Shared packet types: ' + typeHtml + '
' : '') + // Detail tabs '
' + '' + '' + '' + '' + '
' + '
' + '
'; // Bind tab clicks content.addEventListener('click', function handler(e) { var btn = e.target.closest('[data-cview]'); if (btn) { currentView = btn.dataset.cview; content.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); }); btn.classList.add('active'); renderDetail(); return; } // Clickable summary cards var card = e.target.closest('[data-view]'); if (card) { currentView = card.dataset.view; content.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.toggle('active', b.dataset.cview === currentView); }); renderDetail(); } }); renderDetail(); } function renderDetail() { var el = document.getElementById('compareDetail'); if (!el || !comparisonResult) return; var r = comparisonResult; var nameA = escapeHtml(obsName(selA)); var nameB = escapeHtml(obsName(selB)); if (currentView === 'summary') { // Textual summary var stats = computeOverlapStats(r); var total = r.onlyA.length + r.onlyB.length + r.both.length; var overlap = total > 0 ? (r.both.length / total * 100).toFixed(1) : '0.0'; el.innerHTML = '
' + '

In the last 24 hours, ' + nameA + ' saw ' + stats.totalA.toLocaleString() + ' unique packets ' + 'and ' + nameB + ' saw ' + stats.totalB.toLocaleString() + ' unique packets.

' + // #671 — asymmetric reference-observer comparison '
' + '
' + '
' + stats.aSeesOfB.toFixed(1) + '%
' + '
' + nameA + ' saw ' + stats.shared.toLocaleString() + ' of ' + nameB + '\u2019s ' + stats.totalB.toLocaleString() + ' packets
' + '
' + '
' + '
' + stats.bSeesOfA.toFixed(1) + '%
' + '
' + nameB + ' saw ' + stats.shared.toLocaleString() + ' of ' + nameA + '\u2019s ' + stats.totalA.toLocaleString() + ' packets
' + '
' + '
' + '

' + r.both.length.toLocaleString() + ' packets (' + overlap + '%) were seen by both observers. ' + '' + r.onlyA.length.toLocaleString() + ' were exclusive to ' + nameA + ' and ' + '' + r.onlyB.length.toLocaleString() + ' were exclusive to ' + nameB + '.

' + (r.both.length === 0 && total > 0 ? '

\u26A0\uFE0F These observers share no packets \u2014 they may be on different frequencies or too far apart.

' : '') + (r.onlyA.length === 0 && r.onlyB.length === 0 && r.both.length > 0 ? '

\u2705 Perfect overlap \u2014 both observers see the same packets.

' : '') + '
'; return; } var hashes = r[currentView] || []; if (hashes.length === 0) { el.innerHTML = '
No packets in this category.
'; return; } // Show up to 200 packets in the table var displayLimit = 200; var displayed = hashes.slice(0, displayLimit); var mapA = r.packetMapA; var mapB = r.packetMapB; el.innerHTML = (hashes.length > displayLimit ? '
Showing first ' + displayLimit + ' of ' + hashes.length.toLocaleString() + ' packets.
' : '') + '
' + '' + '' + '' + '' + displayed.map(function (h) { var p = mapA.get(h) || mapB.get(h); if (!p) return ''; var typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type; var obsLabel = ''; if (currentView === 'both') { obsLabel = nameA + ', ' + nameB; } else if (currentView === 'onlyA') { obsLabel = nameA; } else { obsLabel = nameB; } return '' + '' + '' + '' + '' + ''; }).join('') + '' + '
HashTimeTypeObserver
' + escapeHtml(h.substring(0, 12)) + '' + timeAgo(p.timestamp || p.first_seen) + '' + escapeHtml(typeName) + '' + obsLabel + '
'; } registerPage('compare', { init: init, destroy: destroy }); })();