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)}`);