mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-11 09:45:47 +00:00
## Summary `transmissions.scope_name` (#899) reached the database but never reached the UI. Two problems, one dead feature and one missing surface. ## 1. The detail pane's Scope row was dead `public/packets.js:3279` has rendered a **Scope** row since #899, gated on `pkt.scope_name != null`. It never fires in practice. `/api/packets` and `/api/packets/{id}` are served from the in-memory `PacketStore`. The store reads `scope_name` out of SQLite fine (`store.go:888`, `chunked_load.go:551` → `StoreTx.ScopeName`), but `txToMap()` did not put it in the JSON. Only packets old enough to have been evicted from the store — and thus served by the SQLite fallback in `db.go`, which does emit it — could ever show a scope. Verified against a live instance before the fix: ``` GET /api/packets/552e9687f1525537 → packet keys: ['_parsedPath','decoded_json','direction','first_seen','hash','id', 'observation_count','observations','observer_iata','observer_id', 'observer_name','path_json','payload_type','raw_hex','route_type','rssi','snr','timestamp'] ``` No `scope_name`. ### The NULL / "" distinction `StoreTx.ScopeName` was typed `string`, which collapses the two states the frontend distinguishes: | DB value | Meaning | UI | |---|---|---| | `NULL` | not transport-scoped | row hidden | | `""` | transport-scoped, region matched no configured key | muted "unknown scope" | | `"#be"` | matched region | the region name | `route_type` is **not** a usable proxy for that distinction: the ingestor writes NULL for a transport route whose `transport_code_1` is `0000` (`cmd/ingestor/db.go:1576` — `IsTransportScoped = route_type IN (0,3) AND Code1 ≠ "0000"`). So the field is now `*string`, with `nullStrPtr` preserving what `nullStrVal` collapsed. The two internal consumers (`TransportedScopes` #1751, `relayEntry.scope`) only care about non-empty named scopes and are unchanged in behaviour. ## 2. New: a Scope column on the packets table The scope was only reachable one packet at a time by opening the detail pane. It now has its own sortable column between Type and Observer, visible by default. The default view is **Group by Hash**, served by mappers that did not carry `scope_name` at all — so the column would have been empty in exactly the view most people look at. Both grouped paths now select and emit it: `groupedTxsToPage` in the store, and the dedicated grouped query in the DB fallback (v3 and legacy shapes). Rendering lives in `scopeCellHtml` (`public/app.js`, next to `transportBadge`) and is used on all three row-render sites — group header, expanded children, flat rows — so the column and the detail pane cannot drift apart. **Sorting** pins the empties last in both directions, as the nodes table already does for `default_scope`. Only ~8% of packets carry a scope, so an ascending sort would otherwise bury every scoped row under a wall of dashes. **Filtering**: `packet-filter.js` gains a `scope` field, so the cell is click-to-filter like Type and Observer, and `scope == "#be"` works in the filter bar. **Column prefs**: a `packets-known-cols` companion key. The `packets-visible-cols` array alone cannot distinguish "this column did not exist when you saved" from "you unchecked it", so any new column arrives silently hidden for every returning visitor. Keys absent from `known-cols` get the default treatment; keys the visitor actually hid stay hidden — there is a test for that second half specifically. ## Tests Each watched fail first. **Go** (`cmd/server/packet_scope_name_test.go`) - `txToMap` unit tests for all three states, including a JSON round-trip so a typed nil `*string` cannot pass as `null` - end-to-end through `/api/packets/{hash}` - `groupedTxsToPage` unit + end-to-end through `/api/packets?groupByHash=true`, across **both** the store-backed and DB-fallback paths - `transported_scopes_1751_test.go`: the "no scope" guard now covers both non-values (nil and a pointer to `""`) **Frontend** - `test-frontend-helpers.js`: `scopeCellHtml` three states + escaping - `test-packet-filter.js`: `scope` matching, case-insensitivity, and `FIELDS` registration - `test-packets-scope-column.js` (new Playwright e2e): header position, default visibility, one cell per row, em dash on non-transport rows, empties-last sorting, the Columns toggle, and the prefs backfill ## Verification Deployed and checked against a live instance: ``` /api/packets?groupByHash=true&limit=500 → scope_name present on 500/500, 59 with a matched region, 1 unknown-scope test-packets-scope-column.js → 7 passed, 0 failed cd cmd/server && go test ./... → ok ``` Two pre-existing failures, unrelated and equally red on an unmodified checkout: `test-e2e-playwright.js` "Customizer open does not overwrite server home config" and `test-observer-iata-1188-e2e.js` (timeout on `[data-loaded="true"]`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
6.6 KiB
JavaScript
154 lines
6.6 KiB
JavaScript
/**
|
|
* Playwright E2E — Scope column on the Packets tab.
|
|
*
|
|
* transmissions.scope_name has three states and the column must render each one
|
|
* distinguishably: em dash (not transport-scoped), muted "unknown"
|
|
* (transport-scoped, region unmatched) and the region name on a match.
|
|
*
|
|
* Usage: node test-packets-scope-column.js
|
|
* BASE_URL=https://staging.on8ar.eu node test-packets-scope-column.js
|
|
*/
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
const results = [];
|
|
|
|
async function test(name, fn) {
|
|
try {
|
|
await fn();
|
|
results.push({ name, pass: true });
|
|
console.log(` ✅ ${name}`);
|
|
} catch (err) {
|
|
results.push({ name, pass: false, error: err.message });
|
|
console.log(` ❌ ${name}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function assert(condition, msg) {
|
|
if (!condition) throw new Error(msg || 'Assertion failed');
|
|
}
|
|
|
|
async function gotoPackets(page) {
|
|
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('packets-visible-cols');
|
|
localStorage.removeItem('packets-known-cols');
|
|
});
|
|
await page.reload({ waitUntil: 'networkidle' });
|
|
await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll])', { timeout: 30000 });
|
|
}
|
|
|
|
(async () => {
|
|
console.log(`\nPackets Scope column — ${BASE}\n`);
|
|
const browser = await chromium.launch();
|
|
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } });
|
|
|
|
await gotoPackets(page);
|
|
|
|
await test('Scope header sits between Type and Observer', async () => {
|
|
const headers = await page.$$eval('#pktTable thead th', ths =>
|
|
ths.map(th => th.textContent.trim()));
|
|
const iType = headers.indexOf('Type');
|
|
const iScope = headers.indexOf('Scope');
|
|
const iObserver = headers.indexOf('Observer');
|
|
assert(iScope !== -1, 'Scope header missing, got: ' + headers.join('|'));
|
|
assert(iType < iScope && iScope < iObserver,
|
|
`expected Type < Scope < Observer, got ${iType} < ${iScope} < ${iObserver}`);
|
|
});
|
|
|
|
await test('Scope column is visible by default', async () => {
|
|
const hidden = await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope'));
|
|
assert(!hidden, 'table carries hide-col-scope on a fresh visit');
|
|
const cellCount = await page.$$eval('#pktTable tbody td.col-scope', tds => tds.length);
|
|
assert(cellCount > 0, 'no td.col-scope rendered');
|
|
});
|
|
|
|
await test('every row renders exactly one scope cell', async () => {
|
|
const { rows, cells } = await page.evaluate(() => {
|
|
const trs = Array.from(document.querySelectorAll('#pktTable tbody tr'))
|
|
.filter(tr => !tr.id.startsWith('vscroll') && tr.querySelector('td.col-type'));
|
|
return {
|
|
rows: trs.length,
|
|
cells: trs.filter(tr => tr.querySelectorAll('td.col-scope').length === 1).length,
|
|
};
|
|
});
|
|
assert(rows > 0, 'no packet rows found');
|
|
assert(rows === cells, `${rows} rows but ${cells} have exactly one scope cell`);
|
|
});
|
|
|
|
await test('non-transport rows render an em dash', async () => {
|
|
const found = await page.evaluate(() => {
|
|
for (const tr of document.querySelectorAll('#pktTable tbody tr')) {
|
|
const type = tr.querySelector('td.col-type');
|
|
const scope = tr.querySelector('td.col-scope');
|
|
if (!type || !scope) continue;
|
|
// No T badge → FLOOD or DIRECT → no transport scope possible.
|
|
if (!type.querySelector('.badge-transport')) return scope.textContent.trim();
|
|
}
|
|
return null;
|
|
});
|
|
assert(found !== null, 'no non-transport row on screen to check');
|
|
assert(found === '—', `expected em dash, got "${found}"`);
|
|
});
|
|
|
|
await test('sorting by Scope pins the empties last', async () => {
|
|
await page.click('#pktTable thead th.col-scope');
|
|
await page.waitForTimeout(600);
|
|
const values = await page.$$eval('#pktTable tbody td.col-scope', tds =>
|
|
tds.map(td => td.textContent.trim()));
|
|
const lastScoped = values.reduce((acc, v, i) => (v !== '—' ? i : acc), -1);
|
|
const firstEmpty = values.indexOf('—');
|
|
if (lastScoped === -1 || firstEmpty === -1) {
|
|
console.log(' (only one scope state on screen, ordering not exercised)');
|
|
return;
|
|
}
|
|
assert(firstEmpty > lastScoped,
|
|
`em dashes must follow every scoped row; first dash at ${firstEmpty}, last scoped at ${lastScoped}`);
|
|
});
|
|
|
|
await test('Columns menu can hide and restore the Scope column', async () => {
|
|
// A checkbox click bubbles to the document handler that closes the menu, so
|
|
// each toggle needs its own open.
|
|
const toggleScope = async () => {
|
|
await page.click('#colToggleBtn');
|
|
await page.waitForTimeout(200);
|
|
const box = await page.$('#colToggleMenu input[data-col="scope"]');
|
|
assert(box, 'no Scope checkbox in the Columns menu');
|
|
const wasChecked = await box.isChecked();
|
|
await box.click();
|
|
await page.waitForTimeout(300);
|
|
return wasChecked;
|
|
};
|
|
|
|
assert(await toggleScope(), 'Scope checkbox should start checked');
|
|
assert(await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope')),
|
|
'unchecking should add hide-col-scope');
|
|
assert(!(await toggleScope()), 'Scope checkbox should now be unchecked');
|
|
assert(!(await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope'))),
|
|
're-checking should remove hide-col-scope');
|
|
});
|
|
|
|
await test('a column added after the visitor saved prefs arrives visible', async () => {
|
|
// Simulate a returning visitor whose stored prefs predate the Scope column.
|
|
await page.evaluate(() => {
|
|
localStorage.setItem('packets-visible-cols',
|
|
JSON.stringify(['time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details']));
|
|
localStorage.removeItem('packets-known-cols');
|
|
});
|
|
await page.reload({ waitUntil: 'networkidle' });
|
|
await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll])', { timeout: 30000 });
|
|
assert(!(await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope'))),
|
|
'Scope should be shown for prefs saved before the column existed');
|
|
// Region was explicitly absent from those prefs AND predates the column, so
|
|
// the backfill must not resurrect it — only genuinely new keys get defaulted.
|
|
assert(await page.$eval('#pktTable', t => t.classList.contains('hide-col-region')),
|
|
'backfill must not re-enable a column the visitor had hidden');
|
|
});
|
|
|
|
await browser.close();
|
|
|
|
const failed = results.filter(r => !r.pass);
|
|
console.log(`\n=== ${results.length - failed.length} passed, ${failed.length} failed ===`);
|
|
process.exit(failed.length ? 1 : 0);
|
|
})();
|