mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 13:44:35 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
57956712e7
commit
5c0de8fb41
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
|
||||
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
|
||||
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
|
||||
<label><input type="checkbox" id="liveMultibyteToggle" aria-describedby="multibyteDesc"> Multibyte only</label>
|
||||
<span id="multibyteDesc" class="sr-only">Show only multibyte (≥2-byte path-hash) packets; hide unreliable single-byte traffic</span>
|
||||
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
|
||||
</div>
|
||||
<div class="live-toggles">
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user