mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-03 06:11:43 +00:00
47f85f6c4c
## What
Adds a per-node **Reach** view that answers "how well does this specific
node hear, and get heard by, its neighbours?" — both as a standalone
page (`#/nodes/{pubkey}/reach`) and as a section on the node detail
page.
New endpoint: **`GET /api/nodes/{pubkey}/reach`**.
## What it measures
For the target node it derives, from raw `path_json` adjacency (a path
travels origin→observer, so in `[A,B]` B received A directly):
- **Directional link counts** per neighbour: `we_hear` (how often we
received them) vs `they_hear` (how often they received us).
- **Bidirectional / bottleneck**: a link is two-way stable when both
directions > 0; the weaker direction is the bottleneck and rates real
two-way reliability.
- **Importance**: neighbour degree + rank, relay-observation volume,
bidirectional-link count, direct-observer count.
- **Direct observers**: who received the node at 0 hops, with SNR.
Reliability rule: a neighbour is only attributed when its pubkey
**prefix is unique** at the path's byte length (collisions are skipped,
never misattributed).
## UI
- Standalone Reach page + node-detail section.
- Reusable bidirectional link map (OSM) with links coloured by
bottleneck.
- Incoming/outgoing toggles to isolate each direction.
## Naming note (deliberate, no collision)
This is distinct from the existing **per-observer reachability** in
topology analytics (`ReachNode` / `ObserverReach` / `perObserverReach`).
This PR adds its own `NodeReach*` response structs in a new
`node_reach.go` and a new `/api/nodes/{pubkey}/reach` route — there are
no symbol or route collisions (verified: `go build ./...` clean). Happy
to rename to disambiguate further (e.g. "Link Quality") if you'd prefer
to reserve "Reach" for the per-observer feature.
## Testing
- `cmd/server`: endpoint shape/404/limit-clamp + unit tests for token
derivation and directional attribution, plus a scan benchmark — all
pass.
- Frontend: helper tests + Reach-page E2E (`test-node-reach-e2e.js`),
standalone route + incoming/outgoing toggles.
- `go build ./...` and `eslint public/*.js` (no-undef) clean.
## Docs
Design spec, implementation plan, and the `GET
/api/nodes/{pubkey}/reach` API contract are included under `docs/`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
59 lines
2.6 KiB
JavaScript
59 lines
2.6 KiB
JavaScript
// E2E for the per-node Reach page (#/nodes/<pubkey>/reach).
|
|
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
|
|
const { chromium } = require('playwright');
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch();
|
|
const page = await browser.newPage();
|
|
|
|
// A repeater is most likely to have reach data (it relays).
|
|
const nodes = await (await page.request.get(BASE + '/api/nodes?role=repeater&limit=1')).json();
|
|
if (!nodes.nodes || !nodes.nodes.length) {
|
|
console.log('node-reach E2E SKIP (no repeater in dataset)');
|
|
await browser.close();
|
|
return;
|
|
}
|
|
const pk = nodes.nodes[0].public_key;
|
|
|
|
// 1. The endpoint returns the documented shape.
|
|
const reach = await (await page.request.get(BASE + '/api/nodes/' + pk + '/reach?days=7')).json();
|
|
for (const k of ['node', 'window', 'reliable_tokens', 'importance', 'links', 'direct_observers']) {
|
|
if (!(k in reach)) throw new Error('reach response missing key: ' + k);
|
|
}
|
|
if (!Array.isArray(reach.links)) throw new Error('reach.links must be an array');
|
|
|
|
// 2. The page renders.
|
|
await page.goto(BASE + '/#/nodes/' + pk + '/reach');
|
|
await page.waitForSelector('.nq-head', { timeout: 20000 });
|
|
if (!(await page.locator('h2', { hasText: 'Reach' }).count())) {
|
|
throw new Error('Reach header missing');
|
|
}
|
|
|
|
// 3. If this node is identifiable, exercise the table, toggles and links.
|
|
if (reach.reliable_tokens.length && (await page.locator('#nqRows').count())) {
|
|
await page.waitForSelector('#nqIncoming');
|
|
await page.waitForSelector('#nqOutgoing');
|
|
|
|
const twoWay = await page.locator('#nqRows tr').count();
|
|
await page.check('#nqIncoming');
|
|
const withIncoming = await page.locator('#nqRows tr').count();
|
|
if (withIncoming < twoWay) throw new Error('incoming toggle reduced rows');
|
|
await page.check('#nqOutgoing');
|
|
const withBoth = await page.locator('#nqRows tr').count();
|
|
if (withBoth < withIncoming) throw new Error('outgoing toggle reduced rows');
|
|
|
|
// Neighbour rows link to a node detail page.
|
|
if (await page.locator('#nqRows a.nq-link').count()) {
|
|
const href = await page.locator('#nqRows a.nq-link').first().getAttribute('href');
|
|
if (!href || !href.startsWith('#/nodes/')) throw new Error('neighbour link malformed: ' + href);
|
|
}
|
|
|
|
// Map renders (node has GPS for a repeater fixture).
|
|
await page.waitForSelector('#nqMap .leaflet-container', { timeout: 10000 }).catch(() => {});
|
|
}
|
|
|
|
console.log('node-reach E2E OK');
|
|
await browser.close();
|
|
})().catch((e) => { console.error(e); process.exit(1); });
|