diff --git a/cmd/server/routes.go b/cmd/server/routes.go index a05c99ca..0a9fcad0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2386,6 +2386,9 @@ func mapSliceToObservations(maps []map[string]interface{}) []ObservationResp { obs.SNR = m["snr"] obs.RSSI = m["rssi"] obs.PathJSON = m["path_json"] + obs.ResolvedPath = m["resolved_path"] + obs.Direction = m["direction"] + obs.RawHex = m["raw_hex"] obs.Timestamp = m["timestamp"] result = append(result, obs) } diff --git a/cmd/server/types.go b/cmd/server/types.go index fc4cbd1d..165ecffd 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -277,6 +277,9 @@ type ObservationResp struct { SNR interface{} `json:"snr"` RSSI interface{} `json:"rssi"` PathJSON interface{} `json:"path_json"` + ResolvedPath interface{} `json:"resolved_path,omitempty"` + Direction interface{} `json:"direction,omitempty"` + RawHex interface{} `json:"raw_hex,omitempty"` Timestamp interface{} `json:"timestamp"` } diff --git a/public/packets.js b/public/packets.js index fbbf9946..b644b354 100644 --- a/public/packets.js +++ b/public/packets.js @@ -387,7 +387,7 @@ 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, resolved_path: obs.resolved_path, timestamp: obs.timestamp, first_seen: obs.timestamp}; + 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, resolved_path: obs.resolved_path, direction: obs.direction, timestamp: obs.timestamp, first_seen: obs.timestamp}; clearParsedCache(obsPacket); selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id); } else { @@ -1246,7 +1246,7 @@ 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, resolved_path: child.resolved_path, timestamp: child.timestamp, first_seen: child.timestamp} : child; + 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, resolved_path: child.resolved_path, direction: child.direction, timestamp: child.timestamp, first_seen: child.timestamp} : child; if (parentData) { clearParsedCache(obsPacket); } selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id); } @@ -1797,7 +1797,7 @@ panel.innerHTML = isMobileNow ? '' : '
' + PANEL_CLOSE_HTML; const content = document.createElement('div'); panel.appendChild(content); - await renderDetail(content, data); + await renderDetail(content, data, selectedObservationId); if (!isMobileNow) initPanelResize(); } catch (e) { panel.innerHTML = `
Error: ${e.message}
`; diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 2610a7b5..8327df5e 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -6116,6 +6116,88 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ===' }); } +// ===== Issue #866: Full-page obs-switch — hex + path must update per observation ===== +{ + console.log('\n=== Issue #866: Full-page observation switch ==='); + + const ctx866 = makeSandbox(); + loadInCtx(ctx866, 'public/roles.js'); + loadInCtx(ctx866, 'public/app.js'); + loadInCtx(ctx866, 'public/packet-helpers.js'); + + test('#866: switching observation updates effectivePkt path_json', () => { + const pkt = { id: 1, hash: 'abc123', observer_id: 'obs-agg', path_json: '["A","B","C","D"]', raw_hex: '0484A1B1C1D1', route_type: 1, timestamp: '2026-01-01T00:00:00Z' }; + const obs1 = { id: 10, observer_id: 'obs-1', path_json: '["A","B"]', snr: 5, rssi: -80, timestamp: '2026-01-01T00:01:00Z' }; + const obs2 = { id: 20, observer_id: 'obs-2', path_json: '["A","B","C","D"]', snr: 8, rssi: -75, timestamp: '2026-01-01T00:02:00Z' }; + + // Simulate renderDetail logic: pick obs1 + const eff1 = ctx866.clearParsedCache({...pkt, ...obs1, _isObservation: true}); + const path1 = ctx866.getParsedPath(eff1); + assert.deepStrictEqual(path1, ['A', 'B']); + assert.strictEqual(eff1.observer_id, 'obs-1'); + assert.strictEqual(eff1.snr, 5); + + // Switch to obs2 + const eff2 = ctx866.clearParsedCache({...pkt, ...obs2, _isObservation: true}); + const path2 = ctx866.getParsedPath(eff2); + assert.deepStrictEqual(path2, ['A', 'B', 'C', 'D']); + assert.strictEqual(eff2.observer_id, 'obs-2'); + assert.strictEqual(eff2.snr, 8); + }); + + test('#866: effectivePkt preserves raw_hex from packet when obs has none', () => { + const pkt = { id: 1, hash: 'h1', raw_hex: '0482AABB', route_type: 1 }; + const obs = { id: 10, observer_id: 'obs-1', path_json: '["AA"]', snr: 3, rssi: -90, timestamp: '2026-01-01T00:00:00Z' }; + const eff = ctx866.clearParsedCache({...pkt, ...obs, _isObservation: true}); + // obs doesn't have raw_hex, so packet's raw_hex survives spread + assert.strictEqual(eff.raw_hex, '0482AABB'); + }); + + test('#866: effectivePkt uses obs raw_hex when available (API now returns it)', () => { + const pkt = { id: 1, hash: 'h1', raw_hex: '0482AABB', route_type: 1 }; + const obs = { id: 10, observer_id: 'obs-1', raw_hex: '0441CC', path_json: '["CC"]', snr: 3, rssi: -90, timestamp: '2026-01-01T00:00:00Z' }; + const eff = ctx866.clearParsedCache({...pkt, ...obs, _isObservation: true}); + // obs has raw_hex from API, should override + assert.strictEqual(eff.raw_hex, '0441CC'); + }); + + test('#866: direction field carried through observation spread', () => { + const pkt = { id: 1, hash: 'h1', direction: 'rx', route_type: 1 }; + const obs = { id: 10, observer_id: 'obs-1', direction: 'tx', path_json: '[]', timestamp: '2026-01-01T00:00:00Z' }; + const eff = {...pkt, ...obs, _isObservation: true}; + assert.strictEqual(eff.direction, 'tx'); + }); + + test('#866: resolved_path carried through observation spread', () => { + const pkt = { id: 1, hash: 'h1', resolved_path: '["aaa","bbb","ccc"]', route_type: 1 }; + const obs = { id: 10, observer_id: 'obs-1', resolved_path: '["aaa"]', path_json: '["AA"]', timestamp: '2026-01-01T00:00:00Z' }; + const eff = ctx866.clearParsedCache({...pkt, ...obs, _isObservation: true}); + const rp = ctx866.getResolvedPath(eff); + assert.deepStrictEqual(rp, ['aaa']); + }); + + test('#866: getPathLenOffset used for hop count cross-check', () => { + // Flood route: offset 1 + assert.strictEqual(ctx866.getPathLenOffset(1), 1); + assert.strictEqual(ctx866.getPathLenOffset(2), 1); + // Transport route: offset 5 + assert.strictEqual(ctx866.getPathLenOffset(0), 5); + assert.strictEqual(ctx866.getPathLenOffset(3), 5); + }); + + test('#866: URL hash should encode obs parameter for deep linking', () => { + // Simulate the URL construction pattern from renderDetail obs click + const pktHash = 'abc123def456'; + const obsId = '42'; + const url = `#/packets/${pktHash}?obs=${obsId}`; + assert.strictEqual(url, '#/packets/abc123def456?obs=42'); + // Parse back + const qIdx = url.indexOf('?'); + const qs = new URLSearchParams(url.substring(qIdx)); + assert.strictEqual(qs.get('obs'), '42'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);