/* === 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 = '
';
// #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 = 'Select observer\u2026 ' +
observers.map(function (o) {
var label = escapeHtml(o.name || o.id);
var region = o.iata ? ' (' + escapeHtml(o.iata) + ')' : '';
return '' + label + region + ' ';
}).join('');
el.innerHTML =
'' +
'
' +
'Observer A ' +
'' + optionsHtml + ' ' +
'
' +
'
vs ' +
'
' +
'Observer B ' +
'' + optionsHtml + ' ' +
'
' +
'
Compare ' +
'
' +
'Packet Type ' +
'' +
'All packets ' +
'Flood only ' +
'Direct only ' +
' ' +
'
' +
'
';
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
'
' +
'Summary ' +
'Both (' + r.both.length + ') ' +
'Only ' + nameA + ' (' + r.onlyA.length + ') ' +
'Only ' + nameB + ' (' + r.onlyB.length + ') ' +
'
' +
'
' +
'
';
// 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.
' : '') +
'';
}
registerPage('compare', { init: init, destroy: destroy });
})();