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})
+
+
+ | Observer |
+ Hops |
+ SNR |
+ RSSI |
+ Time |
+
+ ${observations.map(o => {
+ const oPath = getParsedPath(o);
+ const isCurrent = currentObs && String(o.id) === String(currentObs.id);
+ return `
+ | ${obsName(o.observer_id)} |
+ ${oPath.length} |
+ ${o.snr != null ? o.snr + ' dB' : '—'} |
+ ${o.rssi != null ? o.rssi + ' dBm' : '—'} |
+ ${renderTimestampCell(o.timestamp)} |
+
`;
+ }).join('')}
+
+
` : ''}
+
+ ${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)}`);