fix(#2191): apply the channel filter to live WebSocket packets (#2192)

Fixes #2191.

## Problem

On the Packets page the channel filter only applies to the initial load
(`/api/packets?channel=` → server-side `transmissions.channel_hash`
match). The WebSocket handler re-checks time window, type, observer,
region and node, but not `filters.channel`, and the channel filter is
not re-applied at render time. With a channel selected, every new live
packet is appended regardless of its channel.

## Fix

- New pure helper `packetMatchesChannel(p, channel)` in
`public/packets.js`, exported as `window._packetsMatchesChannelForTest`
(same pattern as `_packetsBuildChannelOptionsForTest`).
- It derives the value the server filters on from `decoded_json`,
because WS packets don't carry `channel_hash`:
  - `channel` when the server decrypted the `GRP_TXT`,
  - otherwise `'enc_' + channelHashHex.toUpperCase()`.
- Only `payload_type === 5` can match, mirroring the server (the column
is only set for `GRP_TXT`). If a packet ever does carry `channel_hash`,
that wins.
- The WS filter calls it: `if (filters.channel &&
!packetMatchesChannel(p, filters.channel)) return false;`

Checked the derivation against a production DB (live.saarmesh.de): it
equals `channel_hash` for all 3,193 `GRP_TXT` rows of the last two days,
0 mismatches.

## Performance

WS hot path, but the new check runs only while a channel filter is
active, and for non-`GRP_TXT` packets it returns before any parsing. For
`GRP_TXT` it is one `JSON.parse` of the already-received `decoded_json`
string, O(1) per packet. WS messages are already debounced/coalesced by
`debouncedOnWS`, so at typical rates (a few packets per second) this is
negligible. No new state, no allocations beyond the parse, no API calls.

## Tests

New `tests/unit/test-packets-live-channel-filter.js` (registered in
`test-all.sh`), 10 cases:

- no filter accepts everything
- decrypted name match / mismatch
- non-`GRP_TXT` rejected while a channel filter is active
- `enc_<HH>` match incl. hex case normalisation and `enc_00`
- `channel_hash` passthrough takes precedence
- already-parsed `decoded_json` object
- malformed / missing `decoded_json` doesn't throw and doesn't match
- source check that the WS filter block actually applies the helper

Without the `packets.js` change the new test fails; with it all pass.
Existing `test-packets.js` (117), `test-packets-local-channels.js` (14),
`test-packet-filter.js` (99) and `test-frontend-helpers.js` still pass.

## Browser validation

The same change is deployed on live.saarmesh.de (hot-patched
`packets.js`, server restarted so `__BUST__` refreshed; the served file
contains the fix). I have not taken a screenshot of the filtered live
table myself, so treat the in-browser check as pending until a reviewer
or operator confirms.

No config or customizer implications.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HJcbiRjQC5cSmydVFXbPbR

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
SaarMesh-Bot
2026-10-12 15:25:59 +02:00
committed by GitHub
co-authored by SaarMesh-Bot Claude
parent 60af9bd469
commit 45680a22cf
3 changed files with 150 additions and 0 deletions
+22
View File
@@ -1246,6 +1246,27 @@
window._packetsCollectLocalChannelsForTest = collectLocalChannels;
}
/**
* Does a live (WebSocket) packet belong to the selected channel filter?
*
* /api/packets?channel=X matches `transmissions.channel_hash = X` server-side:
* the channel name when the server decrypted the GRP_TXT, otherwise the
* synthetic "enc_<HH>". That column is only set for payload_type 5. WS
* packets don't carry it, so derive the same value from decoded_json —
* without this, every new live packet leaks past an active channel filter.
*/
function packetMatchesChannel(p, channel) {
if (!channel) return true;
if (!p || p.payload_type !== 5) return false;
if (p.channel_hash) return p.channel_hash === channel;
let d = p.decoded_json;
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { d = null; } }
d = d || {};
const ch = d.channel || (d.channelHashHex != null ? 'enc_' + String(d.channelHashHex).toUpperCase() : null);
return ch === channel;
}
if (typeof window !== 'undefined') window._packetsMatchesChannelForTest = packetMatchesChannel;
function renderTimestampCell(isoString) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
@@ -1467,6 +1488,7 @@
if (!obs || !selectedRegions.includes(obs.iata)) return false;
}
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
if (filters.channel && !packetMatchesChannel(p, filters.channel)) return false;
return true;
});
if (!filtered.length) return;
+1
View File
@@ -174,6 +174,7 @@ node tests/unit/test-packet-filter-time.js
node tests/unit/test-packet-filter-ux.js
node tests/unit/test-packet-filter.js
node tests/unit/test-packets-local-channels.js
node tests/unit/test-packets-live-channel-filter.js
node tests/unit/test-packets.js
node tests/unit/test-panel-corner.js
node tests/unit/test-path-inspector.js
@@ -0,0 +1,127 @@
/**
* Unit tests for the live (WebSocket) channel filter on the packets page.
*
* The initial load honours ?channel= server-side (transmissions.channel_hash:
* channel name when decrypted, else "enc_<HH>"). Live WS packets don't carry
* that column, so the WS filter in packets.js used to ignore the channel
* selection and every new packet leaked into a channel-filtered table.
*
* packetMatchesChannel() derives the same value from decoded_json; it is pure
* and exported as window._packetsMatchesChannelForTest.
*
* Covers: no filter, decrypted name match/mismatch, non-GRP_TXT rejection,
* encrypted "enc_<HH>" match (case-normalised), channel_hash passthrough,
* already-parsed decoded_json, malformed JSON, and that the WS filter
* actually calls the helper.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
// Same tolerant browser sandbox as test-packets-local-channels.js: the helper
// is exported before any DOM-dependent code runs.
const noop = () => {};
const fakeEl = {
addEventListener: noop, removeEventListener: noop, appendChild: noop, remove: noop,
querySelector: () => null, querySelectorAll: () => [], setAttribute: noop,
getAttribute: () => null, classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
style: {}, dataset: {}, textContent: '', innerHTML: '',
};
const doc = {
readyState: 'complete', createElement: () => ({ ...fakeEl }), head: fakeEl, body: fakeEl,
getElementById: () => null, querySelector: () => null, querySelectorAll: () => [],
addEventListener: noop, removeEventListener: noop,
};
const win = {
addEventListener: noop, removeEventListener: noop,
matchMedia: () => ({ matches: false, addEventListener: noop, removeEventListener: noop }),
PayloadLabels: { SHORT_BY_ID: {} },
};
const ctx = {
window: win, document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Error,
setTimeout, clearTimeout, setInterval, clearInterval,
history: { replaceState: noop, pushState: noop },
location: { hash: '', href: '', pathname: '/' },
navigator: { userAgent: 'node' },
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
registerPage: noop,
api: () => Promise.resolve({}),
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
};
ctx.self = ctx;
vm.createContext(ctx);
const src = fs.readFileSync('public/packets.js', 'utf8');
try {
vm.runInContext(src, ctx);
} catch (e) {
// Downstream init may throw on the stub DOM — the helper is already exported.
}
const match = win._packetsMatchesChannelForTest;
assert.strictEqual(typeof match, 'function', 'packetMatchesChannel must be exported for tests');
let passed = 0;
function test(name, fn) {
fn();
passed++;
console.log(' ✓ ' + name);
}
const grp = (decoded) => ({ payload_type: 5, decoded_json: JSON.stringify(decoded) });
console.log('packetMatchesChannel');
test('no channel filter accepts everything', () => {
assert.strictEqual(match({ payload_type: 4, decoded_json: '{}' }, undefined), true);
assert.strictEqual(match(grp({ channel: '#test' }), ''), true);
});
test('decrypted GRP_TXT matches its channel name', () => {
assert.strictEqual(match(grp({ type: 'CHAN', channel: '#saarland', decryptionStatus: 'decrypted' }), '#saarland'), true);
});
test('decrypted GRP_TXT of another channel is rejected', () => {
assert.strictEqual(match(grp({ type: 'CHAN', channel: '#test', decryptionStatus: 'decrypted' }), '#saarland'), false);
});
test('non-GRP_TXT packets are rejected while a channel filter is active', () => {
assert.strictEqual(match({ payload_type: 4, decoded_json: JSON.stringify({ type: 'ADVERT' }) }, '#saarland'), false);
assert.strictEqual(match({ payload_type: 2, decoded_json: '{}' }, 'enc_5F'), false);
assert.strictEqual(match(null, '#saarland'), false);
});
test('encrypted GRP_TXT matches enc_<HH> (hex case-normalised)', () => {
const p = grp({ type: 'GRP_TXT', channelHash: 95, channelHashHex: '5f', decryptionStatus: 'decryption_failed' });
assert.strictEqual(match(p, 'enc_5F'), true);
assert.strictEqual(match(p, 'enc_76'), false);
assert.strictEqual(match(p, '#saarland'), false);
});
test('encrypted channel hash 00 still derives enc_00', () => {
assert.strictEqual(match(grp({ channelHash: 0, channelHashHex: '00' }), 'enc_00'), true);
});
test('server-provided channel_hash takes precedence', () => {
assert.strictEqual(match({ payload_type: 5, channel_hash: '#saarland', decoded_json: '{}' }, '#saarland'), true);
assert.strictEqual(match({ payload_type: 5, channel_hash: '#test', decoded_json: JSON.stringify({ channel: '#saarland' }) }, '#saarland'), false);
});
test('already-parsed decoded_json object is accepted', () => {
assert.strictEqual(match({ payload_type: 5, decoded_json: { channel: 'Public' } }, 'Public'), true);
});
test('malformed decoded_json does not throw and does not match', () => {
assert.strictEqual(match({ payload_type: 5, decoded_json: '{not json' }, '#saarland'), false);
assert.strictEqual(match({ payload_type: 5 }, '#saarland'), false);
});
test('WS live filter applies the channel filter', () => {
const start = src.indexOf('Check if new packets pass current filters');
assert.ok(start > 0, 'WS filter block not found');
const block = src.slice(start, src.indexOf('if (!filtered.length) return;', start));
assert.ok(/filters\.channel\s*&&\s*!packetMatchesChannel\(p,\s*filters\.channel\)/.test(block),
'WS filter must reject packets that do not match filters.channel');
});
console.log(`\n${passed} passed`);