fix(#1898, #1900): carry observer_id into replayed packets (#1957)

Closes #1898. Closes #1900.

Two issues, one omission.

With a region filter active, VCR replay on the Live map rendered
**nothing at all** (#1898), and the Replay button on packet detail
**silently did nothing** (#1900).

## Cause

`packetMatchesRegion` (`public/live.js:80-92`) matches a packet group by
looking up `packets[i].observer_id` in the observer roster map. A packet
whose `observer_id` is null is skipped, and when none match it returns
`false` and the caller drops the whole group (`live.js:3374`).

`dbPacketToLive()` returned `observer` (the resolved name) but never
`observer_id`. So every replayed packet was skipped, and every group was
dropped. The Replay button had the same gap: both branches passed
`obsName(o.observer_id)` and threw the id itself away.

**The value was there the whole time.** The VCR builds its entries with
`Object.assign({}, p, obs, ...)`, so the observation's `observer_id` is
on the input, and the server has returned `observer_id`, `observer_name`
and `observer_iata` per packet since `cmd/server/db.go:345-347`. Only
the object literal dropped it.

## Fix

Carry `observer_id`, and `observer_iata` alongside it so
`obsIataBadgeHtml` (`live.js:102-108`) can use the direct field for
replayed packets instead of falling back to the roster map.

Three lines of behaviour, in two files.

## Verification

Four regression tests in `test-live-region-filter.js`. **Three fail
without the fix**, checked by reverting `live.js` and re-running:

```
 #1898: dbPacketToLive carries observer_id through
 #1898: a replayed packet survives an active region filter
 #1898: dropping observer_id is what broke it (guards the regression)
 #1898: observer_iata is carried so the badge needs no roster lookup
```

The one that passes either way does so on purpose: it asserts a packet
carrying **no** `observer_id` is still dropped, pinning the mechanism so
a future change cannot make the filter match everything.

That test's sandbox needed `getParsedDecoded` and `getParsedPath`.
`live.js:14` captures those from `packet-helpers.js` at load time and
the sandbox does not load it, so they are stubbed in the sandbox
definition rather than assigned afterwards. Assigning later is too late
for that capture, which cost me two attempts.

Other suites unaffected: `test-live.js` 95 passed,
`test-packet-filter.js` 99, `test-frontend-helpers.js` 656.
`test-1110-live-filter.js` fails identically on unmodified master with
`ERR_CONNECTION_REFUSED`; it is an E2E test needing a server on port
13581.

## Note

Both issues were filed separately and neither names the other. They are
the same root cause in sibling code paths, which is why they are fixed
together rather than in two PRs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efiten
2026-09-04 15:03:08 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent cb6d230573
commit 9488e9c31c
3 changed files with 56 additions and 4 deletions
+7 -1
View File
@@ -845,7 +845,13 @@
resolved_path: pkt.resolved_path,
_ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name,
// #1898: the region filter matches on observer_id (packetMatchesRegion,
// line ~85). Without it every replayed packet has observer_id undefined,
// so the filter skips them all and drops the whole group. observer_iata
// is carried too so obsIataBadgeHtml does not have to fall back to the
// roster map for replayed packets.
observer_id: pkt.observer_id, observer_iata: pkt.observer_iata
};
}
+7 -2
View File
@@ -3449,7 +3449,11 @@
id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex,
_ts: new Date(o.timestamp).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: oDec, path: { hops: oPath } },
snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id)
snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id),
// #1900: carry the id itself, not just the resolved name. The Live
// region filter matches on observer_id, so without it the replay
// silently renders nothing whenever a region is selected.
observer_id: o.observer_id, observer_iata: o.observer_iata
});
}
} else {
@@ -3457,7 +3461,8 @@
id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex,
_ts: new Date(pkt.timestamp).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } },
snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id)
snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id),
observer_id: pkt.observer_id, observer_iata: pkt.observer_iata
});
}
sessionStorage.setItem('replay-packet', JSON.stringify(replayPackets));
+42 -1
View File
@@ -14,7 +14,13 @@ function test(name, fn) {
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
window: {
addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1,
// live.js:14 captures these at load time from packet-helpers.js, which
// this sandbox does not load. They must exist before live.js is evaluated.
getParsedDecoded: (p) => { try { return p && p.decoded_json ? JSON.parse(p.decoded_json) : {}; } catch (e) { return {}; } },
getParsedPath: (p) => { try { return p && p.path_json ? JSON.parse(p.path_json) : []; } catch (e) { return []; } },
},
document: {
readyState: 'complete',
createElement: () => ({ style:{}, classList:{add(){},remove(){},contains(){return false;}}, setAttribute(){}, addEventListener(){}, getContext: () => ({clearRect(){},fillRect(){},beginPath(){},arc(){},fill(){},scale(){},fillText(){}}) }),
@@ -135,7 +141,42 @@ test('observer iata map can be updated and used by filter', () => {
assert.strictEqual(fn([{observer_id:'newobs'}], { 'newobs': 'LAX' }, ['LAX']), true);
});
// --- #1898 / #1900: replayed packets must carry observer_id ---
// dbPacketToLive() used to return only observer (the resolved name), so every
// VCR-replayed packet had observer_id undefined. packetMatchesRegion skips a
// packet whose observer_id is null and returns false when none match, so with a
// region selected the replay rendered nothing at all, and the Replay button on
// packet detail silently no-opped. Both issues are the same omission.
const toLive = ctx.window._liveDbPacketToLive;
assert.ok(toLive, '_liveDbPacketToLive must be exposed');
test('#1898: dbPacketToLive carries observer_id through', () => {
const live = toLive({ id: 1, hash: 'h', timestamp: '2026-01-01T00:00:00Z',
observer_id: 'obs1', observer_name: 'Obs One' });
assert.strictEqual(live.observer_id, 'obs1',
'observer_id must survive; the region filter matches on it');
});
test('#1898: a replayed packet survives an active region filter', () => {
const live = toLive({ id: 1, hash: 'h', timestamp: '2026-01-01T00:00:00Z',
observer_id: 'obs1', observer_name: 'Obs One' });
assert.strictEqual(fn([live], { obs1: 'BRU' }, ['BRU']), true,
'with observer_id carried the group matches and is rendered');
});
test('#1898: dropping observer_id is what broke it (guards the regression)', () => {
assert.strictEqual(fn([{ observer: 'Obs One' }], { obs1: 'BRU' }, ['BRU']), false,
'a packet with no observer_id is skipped, so the group is dropped');
});
test('#1898: observer_iata is carried so the badge needs no roster lookup', () => {
const live = toLive({ id: 1, hash: 'h', timestamp: '2026-01-01T00:00:00Z',
observer_id: 'obs1', observer_iata: 'BRU' });
assert.strictEqual(live.observer_iata, 'BRU');
});
console.log(`\n${'═'.repeat(40)}`);
console.log(` live region filter tests: ${passed} passed, ${failed} failed`);
console.log(`${'═'.repeat(40)}\n`);
if (failed > 0) process.exit(1);