diff --git a/public/packets.js b/public/packets.js index bd5b43fe..e4b1318c 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1804,12 +1804,43 @@ } } - async function renderDetail(panel, data) { + async function renderDetail(panel, data, chosenObsId) { const pkt = data.packet; const breakdown = data.breakdown || {}; const ranges = breakdown.ranges || []; - const decoded = getParsedDecoded(pkt) || {}; - const pathHops = getParsedPath(pkt) || []; + const observations = data.observations || []; + + // Per-observation rendering (issue #849): + // When opened from a packet row (no specific observer), default to first observation. + // When opened from an observation child row, use that observation. + // Clicking a different observation row in the detail re-renders with that observation. + let currentObs = null; + const targetObsId = chosenObsId || selectedObservationId; + if (targetObsId && observations.length) { + currentObs = observations.find(o => String(o.id) === String(targetObsId)); + } + if (!currentObs && observations.length) { + currentObs = observations[0]; // fall back to first observation + } + + // If we have a current observation, build pkt fields from it so summary is per-observation + const effectivePkt = currentObs ? clearParsedCache({...pkt, ...currentObs, _isObservation: true}) : pkt; + const decoded = getParsedDecoded(effectivePkt) || {}; + const pathHops = getParsedPath(effectivePkt) || []; + + // Cross-check: hop count from raw_hex path_len byte vs path_json length + const obsRawHex = effectivePkt.raw_hex || pkt.raw_hex || ''; + let rawHopCount = null; + if (obsRawHex.length >= 4) { + // path_len byte position depends on route type + let plOff = 1; + if (pkt.route_type === 0 || pkt.route_type === 3) plOff = 5; + const plByte = parseInt(obsRawHex.slice(plOff * 2, plOff * 2 + 2), 16); + if (!isNaN(plByte)) rawHopCount = plByte & 0x3F; + } + if (rawHopCount != null && pathHops.length !== rawHopCount) { + console.warn(`[CoreScope] Hop count inconsistency for packet ${pkt.hash}: path_json has ${pathHops.length} hops but raw_hex path_len has ${rawHopCount}. Trusting raw_hex.`); + } // Resolve sender GPS — from packet directly, or from known node in DB let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null); @@ -1856,12 +1887,12 @@ const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN; const hashSize = (isNaN(rawPathByte) || (rawPathByte & 0x3F) === 0) ? null : ((rawPathByte >> 6) + 1); - const size = pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0; + const size = effectivePkt.raw_hex ? Math.floor(effectivePkt.raw_hex.length / 2) : (pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0); const typeName = payloadTypeName(pkt.payload_type); - const snr = pkt.snr ?? decoded.SNR ?? decoded.snr ?? null; - const rssi = pkt.rssi ?? decoded.RSSI ?? decoded.rssi ?? null; - const hasRawHex = !!pkt.raw_hex; + const snr = effectivePkt.snr ?? decoded.SNR ?? decoded.snr ?? null; + const rssi = effectivePkt.rssi ?? decoded.RSSI ?? decoded.rssi ?? null; + const hasRawHex = !!(effectivePkt.raw_hex || pkt.raw_hex); // Build message preview let messageHtml = ''; @@ -1882,7 +1913,6 @@ `; } - const observations = data.observations || []; const obsCount = data.observation_count || observations.length || 1; const uniqueObservers = new Set(observations.map(o => o.observer_id)).size; @@ -1945,21 +1975,28 @@ ? `
⚠️ Anomaly: ${escapeHtml(decoded.anomaly)}
` : ''; + // Hop count display: trust raw_hex (firmware truth) over path_json + const displayHopCount = rawHopCount != null ? rawHopCount : pathHops.length; + const obsIndicator = currentObs && observations.length > 1 + ? `(observation ${observations.indexOf(currentObs) + 1} of ${observations.length})` + : ''; + panel.innerHTML = ` ${anomalyBanner}
${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}
-
${pkt.hash || 'Packet #' + pkt.id}
+
${pkt.hash || 'Packet #' + pkt.id}${obsIndicator}
${messageHtml}
-
Observer
${obsName(pkt.observer_id)}
+
Observer
${obsName(effectivePkt.observer_id)}
Location
${locationHtml}
SNR / RSSI
${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}
Route Type
${routeTypeName(pkt.route_type)}
Payload Type
${typeName}
${hashSize ? `
Hash Size
${hashSize} byte${hashSize !== 1 ? 's' : ''}
` : ''} -
Timestamp
${renderTimestampCell(pkt.timestamp)}
+
Timestamp
${renderTimestampCell(effectivePkt.timestamp)}
Propagation
${propagationHtml}
-
Path
${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}
+
Path
${displayHopCount > 0 ? `${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''} ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}
+ ${effectivePkt.direction ? `
Direction
${escapeHtml(effectivePkt.direction)}
` : ''}
@@ -1969,11 +2006,59 @@
${hasRawHex ? `
${buildHexLegend(ranges)}
-
${createColoredHexDump(pkt.raw_hex, ranges)}
` : ''} +
${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}
` : ''} - ${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)} + ${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)} + + ${observations.length > 1 ? ` +
+
Observations (${observations.length})
+ + + + + + + + + ${observations.map(o => { + const oPath = getParsedPath(o); + const isCurrent = currentObs && String(o.id) === String(currentObs.id); + return ` + + + + + + `; + }).join('')} +
ObserverHopsSNRRSSITime
${obsName(o.observer_id)}${oPath.length}${o.snr != null ? o.snr + ' dB' : '—'}${o.rssi != null ? o.rssi + ' dBm' : '—'}${renderTimestampCell(o.timestamp)}
+
` : ''} + + ${observations.length > 1 ? (() => { + // Cross-observer aggregate (Option B): show longest observed path across all observers + const aggregatePath = getParsedPath(pkt) || []; + return `
+
Cross-observer aggregate
+
Longest observed path: ${aggregatePath.length ? `${aggregatePath.length} hops — ${renderPath(aggregatePath, pkt.observer_id)}` : '— (direct)'}
+
Longest path seen across all ${uniqueObservers} observer${uniqueObservers !== 1 ? 's' : ''}
+
`; + })() : ''} `; + // Wire up observation row click handlers — re-render detail with clicked observation + panel.querySelectorAll('.detail-obs-row').forEach(row => { + row.addEventListener('click', () => { + const obsId = row.dataset.obsId; + selectedObservationId = obsId; + // Update URL hash to reflect selected observation (deep linking) + const pktHash = pkt.hash || pkt.id; + const obsParam = obsId ? `?obs=${obsId}` : ''; + history.replaceState(null, '', `#/packets/${pktHash}${obsParam}`); + renderDetail(panel, data, obsId); + }); + }); + // Wire up copy link button const copyLinkBtn = panel.querySelector('.copy-link-btn'); if (copyLinkBtn) { diff --git a/public/style.css b/public/style.css index 3d8e6467..f1c62078 100644 --- a/public/style.css +++ b/public/style.css @@ -345,6 +345,9 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible } .detail-meta dt { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .3px; } .detail-meta dd { font-weight: 500; margin-bottom: 4px; } +.observation-current { background: var(--accent-bg, rgba(0,122,255,0.1)); font-weight: 600; } +.detail-obs-row:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); } +.detail-obs-table th { font-size: 0.8em; text-transform: uppercase; color: var(--text-muted); } /* === Hex Dump === */ .hex-dump { diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 7b2f060d..166f1e4f 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -5887,6 +5887,87 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ===' }); } +// ===== Issue #849: Per-observation packet detail tests ===== +{ + console.log('\n=== Issue #849: Per-observation packet detail ==='); + + // Test helper: extract hop count from raw_hex path_len byte + function extractRawHopCount(rawHex, routeType) { + if (!rawHex || rawHex.length < 4) return null; + let plOff = 1; + if (routeType === 0 || routeType === 3) plOff = 5; + const plByte = parseInt(rawHex.slice(plOff * 2, plOff * 2 + 2), 16); + if (isNaN(plByte)) return null; + return plByte & 0x3F; + } + + test('#849: hop count from raw_hex path_len byte (2 hops)', () => { + // path_len byte = 0x82: hash_size=2+1=3, hash_count=2 + const rawHex = '0482aabbccddee'; // header + path_len(0x82) + path data + assert.strictEqual(extractRawHopCount(rawHex, 1), 2); + }); + + test('#849: hop count from raw_hex path_len byte (0 hops = direct)', () => { + const rawHex = '0400'; // header + path_len=0x00 + assert.strictEqual(extractRawHopCount(rawHex, 1), 0); + }); + + test('#849: hop count from raw_hex for transport route (offset 5)', () => { + // Transport routes have 4 bytes of transport codes before path_len + const rawHex = '00112233440541B127D7'; // header + 4 transport bytes + path_len(0x05)=5 hops + assert.strictEqual(extractRawHopCount(rawHex, 0), 5); + }); + + test('#849: hop count warns on inconsistency (path_json vs raw_hex)', () => { + // path_json has 3 hops, but raw_hex says 2 + const pathJson = ['41B1', '27D7', '5EB0']; + const rawHopCount = 2; + assert.notStrictEqual(pathJson.length, rawHopCount, 'should detect inconsistency'); + // In production code, rawHopCount is trusted + assert.strictEqual(rawHopCount, 2); + }); + + test('#849: per-observation fields override aggregated packet fields', () => { + const pkt = { id: 1, hash: 'abc', observer_id: 'obs-agg', snr: 10, rssi: -90, path_json: '["A","B","C"]', timestamp: '2026-01-01T00:00:00Z' }; + const obs = { id: 2, observer_id: 'obs-1', snr: 5, rssi: -85, path_json: '["A"]', timestamp: '2026-01-01T00:01:00Z' }; + // Simulate what renderDetail does: spread obs over pkt + const effective = {...pkt, ...obs, _isObservation: true}; + delete effective._parsedPath; // clear cache + assert.strictEqual(effective.observer_id, 'obs-1'); + assert.strictEqual(effective.snr, 5); + assert.strictEqual(effective.rssi, -85); + assert.strictEqual(effective.timestamp, '2026-01-01T00:01:00Z'); + }); + + test('#849: first observation used when no specific observation selected', () => { + const observations = [ + { id: 10, observer_id: 'obs-A', path_json: '["X"]' }, + { id: 20, observer_id: 'obs-B', path_json: '["X","Y","Z"]' } + ]; + // No targetObsId → use observations[0] + const currentObs = observations[0]; + assert.strictEqual(currentObs.id, 10); + assert.strictEqual(currentObs.observer_id, 'obs-A'); + }); + + test('#849: clicking observation row selects that observation', () => { + const observations = [ + { id: 10, observer_id: 'obs-A', path_json: '["X"]' }, + { id: 20, observer_id: 'obs-B', path_json: '["X","Y","Z"]' } + ]; + const targetObsId = '20'; + const currentObs = observations.find(o => String(o.id) === String(targetObsId)); + assert.ok(currentObs); + assert.strictEqual(currentObs.observer_id, 'obs-B'); + }); + + test('#849: null/missing raw_hex returns null hop count', () => { + assert.strictEqual(extractRawHopCount(null, 1), null); + assert.strictEqual(extractRawHopCount('', 1), null); + assert.strictEqual(extractRawHopCount('04', 1), null); // too short + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);