From 5c0de8fb41ffa4cb00eaa776bed67c607701201c Mon Sep 17 00:00:00 2001 From: efiten Date: Tue, 23 Jun 2026 12:56:08 +0200 Subject: [PATCH] feat(live): optional "Multibyte only" view filter (#1780) (#1781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1780. ## What Adds an opt-in **"Multibyte only"** toggle to the live map controls. When ON, packets whose path hash size is `< 2` bytes (single-byte, or unresolvable) are excluded from the entire live view — feed, map polylines/rain, and the packet counter — in both LIVE and REPLAY modes. - **Default OFF** — no behavior change for existing users. - Persisted in `localStorage` under `live-multibyte-only`. - Distinct from the existing global "hide 1-byte path hops" toggle: that filters individual hops within a path at every render site; this filters whole packets, on the live view only. They share no state. ## How - **`public/hop-filter.js`** — new pure, dependency-free classifier `MC_packetHashSize(rawHex, routeType)` returning `1|2|3`, or `0` when unresolvable. Reads the path-length byte from `raw_hex` (`(pathByte >> 6) + 1`), offset `5` for transport routes (route_type 0/3) else `1` — mirroring the existing `getPathLenOffset`/`computeBreakdownRanges` logic in `app.js`. Lives next to the existing `hopByteLen`/`MC_*` family; `app.js` is untouched (no duplication of the byte math). - **`public/live.js`** — `groupIsMultibyte(packets)` consumes that helper; applied at two render-time sites: the top of `renderPacketTree` (above the counter increment, so the counter reflects multibyte-only) and inside the `rebuildFeedList` group loop (so toggling re-filters the buffered feed). Toggle markup + change handler mirror the existing `liveFavoritesToggle` pattern. ## Why read from `raw_hex` and not the path hops The hash size is a property of the whole packet and is present even for zero-hop packets (where there are no hops to inspect), so reading the path-length byte is correct in all cases. Unresolvable size is treated as single-byte (excluded when ON) — we only show packets we can positively confirm are multibyte. ## Performance (hot path) The filter runs in the packet-render hot path, so: classification is **O(1) per packet group** — it reads the first resolvable observation's `raw_hex` (a short hex string, single `parseInt` of one byte) and short-circuits. No per-packet API calls, no allocation in the loop, no added O(n²). When the toggle is OFF (default) the check is a single boolean guard and does nothing else. The buffered-feed re-filter reuses the existing `rebuildFeedList` pass — no extra traversal. ## Tests - **Unit** (`test-live-multibyte-filter.js`, 9 cases): single/2-byte/3-byte classification, transport-route offset, missing/short/garbage `raw_hex` → 0, whitespace tolerance. - **E2E** (`test-live-multibyte-only-e2e.js`, Playwright): toggle present and defaults OFF; ON hides a single-byte packet while a multibyte one renders; OFF restores it; setting persists across reload. Registered in the CI live-E2E block in `deploy.yml`. ## Docs User-guide entry added in `docs/user-guide/live.md`. --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/deploy.yml | 1 + docs/user-guide/live.md | 1 + public/hop-filter.js | 19 +++++ public/live.js | 32 ++++++++ test-all.sh | 1 + test-live-multibyte-filter.js | 84 ++++++++++++++++++++ test-live-multibyte-only-e2e.js | 131 ++++++++++++++++++++++++++++++++ 7 files changed, 269 insertions(+) create mode 100644 test-live-multibyte-filter.js create mode 100644 test-live-multibyte-only-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2669038..6df8c230 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -410,6 +410,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-live-multibyte-only-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/docs/user-guide/live.md b/docs/user-guide/live.md index e6701e0f..d22b5b4e 100644 --- a/docs/user-guide/live.md +++ b/docs/user-guide/live.md @@ -67,6 +67,7 @@ Each packet type has a color and icon: ## Controls - **Favorites only** — show only packets from your claimed nodes +- **Multibyte only** — show only multibyte (≥2-byte path-hash) packets and hide single-byte traffic. Single-byte path hashes collide heavily at scale, so their paths are unreliable; enable this for a cleaner, more trustworthy live picture. Off by default; the choice is remembered. - **Matrix mode** — visual effect overlay (just for fun) ## Tips diff --git a/public/hop-filter.js b/public/hop-filter.js index 47c78560..d32be9af 100644 --- a/public/hop-filter.js +++ b/public/hop-filter.js @@ -40,6 +40,23 @@ return s.length >> 1; } + // Packet-level path-hash size (1|2|3), or 0 when unresolvable. + // Reads the path-length byte from raw_hex; its top two bits encode + // hashSize-1. Offset is 5 for transport routes (route_type 0/3, which + // carry 4 next/last-hop code bytes before path-length) else 1 — mirrors + // getPathLenOffset()/computeBreakdownRanges() in public/app.js. Reading + // from raw_hex (not path hops) is correct even for zero-hop packets. + function packetHashSize(rawHex, routeType) { + if (!rawHex || typeof rawHex !== 'string') return 0; + var clean = rawHex.replace(/\s+/g, ''); + var bytes = clean.length >> 1; + var offset = (routeType === 0 || routeType === 3) ? 5 : 1; + if (bytes < offset + 1) return 0; + var pathByte = parseInt(clean.slice(offset * 2, offset * 2 + 2), 16); + if (isNaN(pathByte)) return 0; + return (pathByte >> 6) + 1; + } + // Render-time predicate. opts may be omitted — if so, falls back to the // current localStorage value. The hop is hidden only when: // - opts.hide1ByteHops === true AND @@ -77,6 +94,7 @@ window.MC_isVisibleHop = isVisibleHop; window.MC_filterPathHops = filterPathHops; window.MC_hopByteLen = hopByteLen; + window.MC_packetHashSize = packetHashSize; } if (typeof module !== 'undefined' && module.exports) { @@ -86,6 +104,7 @@ isVisibleHop: isVisibleHop, filterPathHops: filterPathHops, hopByteLen: hopByteLen, + packetHashSize: packetHashSize, _STORAGE_KEY: STORAGE_KEY }; } diff --git a/public/live.js b/public/live.js index e8e45f67..a921e12a 100644 --- a/public/live.js +++ b/public/live.js @@ -39,6 +39,7 @@ let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false'; let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true'; let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true'; + let multibyteOnly = localStorage.getItem('live-multibyte-only') === 'true'; let matrixMode = localStorage.getItem('live-matrix-mode') === 'true'; let matrixRain = localStorage.getItem('live-matrix-rain') === 'true'; let colorByHash = localStorage.getItem('meshcore-color-packets-by-hash') !== 'false'; @@ -1107,6 +1108,8 @@ Sonify packets — turn raw bytes into generative music Show only favorited and claimed nodes + + Show only multibyte (≥2-byte path-hash) packets; hide unreliable single-byte traffic
@@ -1573,6 +1576,14 @@ applyFavoritesFilter(); }); + const multibyteToggle = document.getElementById('liveMultibyteToggle'); + multibyteToggle.checked = multibyteOnly; + multibyteToggle.addEventListener('change', (e) => { + multibyteOnly = e.target.checked; + localStorage.setItem('live-multibyte-only', multibyteOnly); + rebuildFeedList(); + }); + // Region filter (#1045): dropdown of observer IATA regions (function initLiveRegionFilter() { var rfEl = document.getElementById('liveRegionFilter'); @@ -2794,6 +2805,7 @@ const sorted = [...byHash.values()].sort((a, b) => b.latestTs - a.latestTs).slice(0, 25); for (const group of sorted) { + if (multibyteOnly && !groupIsMultibyte(group.packets)) continue; const pkt = Object.assign({}, group.latestPkt, { observation_count: group.count }); const decoded = pkt.decoded || {}; const header = decoded.header || {}; @@ -3149,11 +3161,31 @@ ws.onerror = () => {}; } + // A packet group is multibyte when its path hash size is >= 2 bytes. + // All observations of one packet share the same hash size; use the first + // observation with a resolvable raw_hex. Unresolvable => treated as single + // (excluded when the "Multibyte only" filter is on). + function groupIsMultibyte(packets) { + if (!packets || !packets.length) return false; + for (var i = 0; i < packets.length; i++) { + var p = packets[i]; + var size = window.MC_packetHashSize + ? window.MC_packetHashSize(p.raw_hex, p.route_type) + : 0; + if (size > 0) return size >= 2; + } + return false; + } + // === UNIFIED PACKET RENDERER === // ONE function for all rendering: WS arrival, DB load, replay button, VCR playback. // Takes an array of observations (same hash) and renders the complete path tree. function renderPacketTree(packets, isReplay) { if (!packets || !packets.length) return; + // "Multibyte only" filter: drop single-byte / unresolvable packets from the + // entire live view (feed, map, rain, counter — live AND replay). Placed + // above the counter so livePktCount reflects multibyte-only traffic. + if (multibyteOnly && !groupIsMultibyte(packets)) return; const first = packets[0]; const decoded = first.decoded || {}; const header = decoded.header || {}; diff --git a/test-all.sh b/test-all.sh index 2306b221..9b7c1d55 100755 --- a/test-all.sh +++ b/test-all.sh @@ -34,6 +34,7 @@ node test-issue-1648-m3-emoji-scan.js node test-issue-1648-m6-final-sweep.js node test-issue-1648-m6-lint-self.js node test-traces.js +node test-live-multibyte-filter.js # #1418 — route-view v2 (Tufte) coverage node test-issue-1418-raw-hex-extraction.js diff --git a/test-live-multibyte-filter.js b/test-live-multibyte-filter.js new file mode 100644 index 00000000..1cc2e896 --- /dev/null +++ b/test-live-multibyte-filter.js @@ -0,0 +1,84 @@ +/* test-live-multibyte-filter.js + * feat(live): packet-level multibyte classifier used by the "Multibyte only" + * live filter. Pure function — no DOM. Mirrors app.js hash-size math: + * hashSize = (pathByte >> 6) + 1, pathByte at offset 5 (transport) else 1. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +// Load hop-filter.js into a sandbox exposing module.exports. +function load() { + const ctx = { + window: { addEventListener() {}, dispatchEvent() {} }, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + console, Math, Number, String, JSON, parseInt, isNaN, + module: { exports: {} }, exports: {}, + }; + ctx.window.localStorage = ctx.localStorage; + vm.createContext(ctx); + const src = fs.readFileSync(path.join(__dirname, 'public/hop-filter.js'), 'utf8'); + vm.runInContext(src, ctx, { filename: 'hop-filter.js' }); + return ctx.module.exports.packetHashSize; +} + +const packetHashSize = load(); + +console.log('\n=== packetHashSize (multibyte classifier) ==='); + +test('non-transport single-byte: byte1 top bits 00 -> size 1', () => { + // byte0=0x10 (header), byte1=0x00 (hashSize-1=0, hashCount=0) + assert.strictEqual(packetHashSize('1000', 1), 1); +}); + +test('non-transport multibyte: byte1 0x40 -> size 2', () => { + // byte1=0x40 = 0b01000000 -> (0x40>>6)+1 = 2 + assert.strictEqual(packetHashSize('1040', 1), 2); +}); + +test('non-transport 3-byte: byte1 0x80 -> size 3', () => { + // byte1=0x80 = 0b10000000 -> (0x80>>6)+1 = 3 + assert.strictEqual(packetHashSize('1080', 1), 3); +}); + +test('transport route uses offset 5 (route_type 0)', () => { + // bytes: 00 | 4 transport-code bytes | path-len byte 0x40 | ... + // 0 1 2 3 4 5 + assert.strictEqual(packetHashSize('00aabbccdd40', 0), 2); +}); + +test('transport route_type 3 also uses offset 5', () => { + assert.strictEqual(packetHashSize('03aabbccdd00', 3), 1); +}); + +test('missing raw_hex -> 0 (unresolvable)', () => { + assert.strictEqual(packetHashSize('', 1), 0); + assert.strictEqual(packetHashSize(null, 1), 0); + assert.strictEqual(packetHashSize(undefined, 1), 0); +}); + +test('too short for offset -> 0', () => { + // non-transport needs >= 2 bytes; only 1 byte present + assert.strictEqual(packetHashSize('10', 1), 0); + // transport needs >= 6 bytes; only 5 present + assert.strictEqual(packetHashSize('00aabbccdd', 0), 0); +}); + +test('garbage hex byte -> 0', () => { + assert.strictEqual(packetHashSize('10zz', 1), 0); +}); + +test('whitespace in raw_hex is tolerated', () => { + assert.strictEqual(packetHashSize('10 40', 1), 2); +}); + +console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---\n'); +process.exit(failed > 0 ? 1 : 0); diff --git a/test-live-multibyte-only-e2e.js b/test-live-multibyte-only-e2e.js new file mode 100644 index 00000000..d33fa7c8 --- /dev/null +++ b/test-live-multibyte-only-e2e.js @@ -0,0 +1,131 @@ +/** + * E2E: live "Multibyte only" toggle. + * 1. Toggle exists in live controls and defaults OFF. + * 2. With it ON, a single-byte synthetic packet does NOT create a feed item, + * while a multibyte one does. + * 3. Turning it OFF and rebuilding shows the previously-hidden single-byte pkt. + * 4. The setting persists across a reload (localStorage round-trip). + * + * Usage: BASE_URL=http://localhost:13581 node test-live-multibyte-only-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +// raw_hex builders (non-transport, route_type 1 => path-len byte at offset 1). +// byte0 = 0x10 header; byte1 top bits set hash size. +const SINGLE_HEX = '1000'; // (0x00>>6)+1 = 1 +const MULTI_HEX = '1040'; // (0x40>>6)+1 = 2 + +function makePkt(hash, rawHex) { + return { + id: Math.floor(Math.random() * 1e9), + hash: hash, + raw_hex: rawHex, + route_type: 1, + path_json: '[]', + observer_id: 'mb-e2e-obs', + observer_name: 'mb-e2e', + timestamp: new Date().toISOString(), + snr: 5, rssi: -90, + decoded: { + header: { payloadTypeName: 'GRP_TXT' }, + payload: { text: 'mb-probe' }, + path: { hops: [] }, + }, + }; +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + console.log('\n=== live multibyte-only E2E against ' + BASE + ' ==='); + + await step('navigate to /#/live, toggle exists and defaults OFF', async () => { + await page.addInitScript(() => { + try { localStorage.removeItem('live-multibyte-only'); } catch (e) {} + }); + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!window._liveBufferPacket, { timeout: 15000 }); + const cb = await page.$('#liveMultibyteToggle'); + assert(cb, '#liveMultibyteToggle must exist in the live controls'); + const checked = await page.evaluate(() => document.getElementById('liveMultibyteToggle').checked); + assert(checked === false, 'multibyte toggle must default OFF'); + }); + + const singleHash = 'mb-single-' + Date.now().toString(16); + const multiHash = 'mb-multi-' + Date.now().toString(16); + + await step('turn toggle ON: single-byte packet hidden, multibyte shown', async () => { + await page.evaluate(() => { + const cb = document.getElementById('liveMultibyteToggle'); + cb.checked = true; + cb.dispatchEvent(new Event('change', { bubbles: true })); + }); + await page.evaluate((args) => { + window._liveBufferPacket(args.single); + window._liveBufferPacket(args.multi); + }, { single: makePkt(singleHash, SINGLE_HEX), multi: makePkt(multiHash, MULTI_HEX) }); + + await page.waitForFunction((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), + multiHash, { timeout: 5000 }).catch(() => {}); + + const multiShown = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), multiHash); + const singleShown = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), singleHash); + assert(multiShown, 'multibyte packet should render a feed item when toggle ON'); + assert(!singleShown, 'single-byte packet must NOT render a feed item when toggle ON'); + }); + + await step('turn toggle OFF: previously-hidden single-byte packet appears', async () => { + await page.evaluate(() => { + const cb = document.getElementById('liveMultibyteToggle'); + cb.checked = false; + cb.dispatchEvent(new Event('change', { bubbles: true })); + }); + await page.waitForFunction((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), + singleHash, { timeout: 5000 }).catch(() => {}); + const singleShown = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), singleHash); + assert(singleShown, 'single-byte packet should reappear after toggle OFF + feed rebuild'); + }); + + await step('setting persists across reload', async () => { + // Ensure the toggle is ON and localStorage is written. + await page.evaluate(() => { + const cb = document.getElementById('liveMultibyteToggle'); + cb.checked = true; + cb.dispatchEvent(new Event('change', { bubbles: true })); + }); + // Open a fresh context (no addInitScript) to simulate a cold reload. + const persistCtx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const persistPage = await persistCtx.newPage(); + persistPage.setDefaultTimeout(15000); + // Seed localStorage before navigation so the page boots with it set. + await persistPage.addInitScript(() => { + localStorage.setItem('live-multibyte-only', 'true'); + }); + await persistPage.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await persistPage.waitForFunction(() => !!document.getElementById('liveMultibyteToggle'), { timeout: 15000 }); + const checked = await persistPage.evaluate(() => document.getElementById('liveMultibyteToggle').checked); + await persistCtx.close(); + assert(checked === true, 'multibyte toggle should restore checked=true from localStorage'); + }); + + await browser.close(); + console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---\n'); + process.exit(failed > 0 ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); });