fix(nodes): paginate /api/nodes across map/live/analytics/packets/area-map (500-row cap) (#1637)

## Summary

The server clamps `/api/nodes` `?limit` to **500** (DoS guard, PR #1540
/ v3.8.3) and orders by `last_seen DESC`. Every node-list consumer
issued a single big-`?limit` fetch and trusted it as the full set, so on
>500-node meshes the top-500-by-advert window silently hid the tail.

Because `nodes.last_seen` is updated **only on self-adverts** (never on
relay traffic; `UpsertNode` is called solely from the advert path), a
repeater that relays constantly but last advertised hours ago fell
outside that window and **vanished from the map and live view** — while
still showing "Active" in its detail panel and (since #1606) in the
paginated Nodes list.

#1606 fixed only the Nodes page (`nodes.js`). This generalizes that fix
to the deferred siblings.

## Changes

- **`public/app.js`** — new shared `fetchAllNodes(extraQuery, opts)`:
pages `limit=500` + `offset` until a short page (the server's `total` is
unreliable — clamped to the page size and overwritten with the filtered
length under area/region filters, so we stop on a short page, not on
`total`), dedups by `public_key`, returns the real deduped count as
`total`.
- **`public/map.js`**, **`public/live.js`** (keeps the
`LIVE_MAP_MAX_NODES` ceiling via `safetyCap`), **`public/analytics.js`**
(×2), **`public/packets.js`** now use the helper.
- **`public/area-map.html`** is standalone (cross-origin `baseUrl`, no
`app.js`) so it gets an inline copy of the same loop.
- **`.eslintrc.json`** — declare `fetchAllNodes` global (no-undef).

## Tests

- **`test-fetch-all-nodes-pagination.js`** — unit-tests the helper via
the real `api()`+`fetch` path: pagination past 500, short-page stop vs.
the unreliable server `total`, dedup across a page boundary, counts
pass-through, `safetyCap` bound. 5/5.
- **`test-map-nodes-pagination-e2e.js`** — browser E2E (Playwright)
proving `map.js` surfaces a 501st node reachable only on page 2 and
renders its marker. Verified **red→green**: against the pre-fix single
fetch all 3 assertions fail (500 nodes, page-2 node absent, no marker);
after the fix all pass. Wired into `deploy.yml`.

## Verification

- unit 5/5, E2E 3/3, `test-frontend-helpers.js` 611/611, `npx eslint
public/*.js` → 0 errors.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-06-09 13:24:08 +02:00
committed by GitHub
parent c5414b33b7
commit 9002b25bce
11 changed files with 500 additions and 23 deletions
+1
View File
@@ -209,6 +209,7 @@
"escapeHtml": "readonly",
"exports": "readonly",
"favStar": "readonly",
"fetchAllNodes": "readonly",
"filterPacketsByRoute": "readonly",
"formatAbsoluteTimestamp": "readonly",
"formatChartAxisLabel": "readonly",
+1
View File
@@ -354,6 +354,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-map-nodes-pagination-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
+2 -2
View File
@@ -2148,7 +2148,7 @@
try {
const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString();
const [nodesResp, bulkHealth] = await Promise.all([
api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF })
]);
const nodes = nodesResp.nodes || nodesResp;
@@ -2885,7 +2885,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
let nodesResp, hashSizesResp;
try {
[nodesResp, hashSizesResp] = await Promise.all([
api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
// #1270: fetch CONFIGURED-hash-size counts so the Network Overview
// tells the operational story (matching Hash Stats "By Repeaters"),
// not just a math-only count of unique pubkey slices.
+49
View File
@@ -130,6 +130,55 @@ async function api(path, { ttl = 0, bust = false } = {}) {
return promise;
}
// Fetch the COMPLETE /api/nodes set, transparently paging around the server's
// per-request row cap. /api/nodes clamps ?limit to `listLimits.nodesMax`
// (default 2000, operator-configurable; originally a hard 500 in PR #1540,
// raised/made configurable in PR #1589). A single ?limit=N fetch therefore
// silently truncates to the top nodesMax rows by last_seen DESC, so on a mesh
// with more nodes than that cap every node-list consumer (map, live,
// analytics, packets, area-map) loses the older-advert tail — a node that
// relays constantly but last self-advertised hours ago drops off the map even
// though it is plainly alive. #1606 fixed this for the Nodes page; this helper
// generalizes the same loop for all callers, using a fixed client page size
// well under the server cap.
//
// extraQuery: query fragment appended after the paged limit/offset, each piece
// already '&'-prefixed exactly as callers build it today
// (e.g. '&lastHeard=30d&area=x', '&sortBy=lastSeen&region=y'); pass '' for none.
// safetyCap: hard ceiling on BOTH pages fetched and nodes returned — the result
// is sliced to it (callers like live.js pass their render ceiling here).
// Returns { nodes, counts, total }: counts is from the first page; total is the
// real deduped/capped node count (NOT the server's per-query `total`).
async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyCap = 10000 } = {}) {
const accumulated = [];
let counts = {};
for (let offset = 0; offset < safetyCap; offset += pageSize) {
const data = await api(`/nodes?limit=${pageSize}&offset=${offset}${extraQuery}`, { ttl });
const page = data && Array.isArray(data.nodes) ? data.nodes
: (Array.isArray(data) ? data : []);
accumulated.push.apply(accumulated, page);
if (offset === 0) counts = (data && data.counts) || {};
// Canonical stop: a short page is the end. The server's `total` is a real
// COUNT(*) for the query, but the handler overwrites it with the filtered
// length under area/geo/blacklist filtering — so we never loop on it, nor
// surface it; a short page is the reliable end-of-data signal. See #1606.
if (page.length < pageSize) break;
}
// Dedup by public_key: the sort window (last_seen DESC by default) can shift
// under concurrent ingest, repeating a row across a page boundary. Rows
// missing a public_key get a unique synthetic key so they are NOT collapsed.
const seen = new Map();
for (let i = 0; i < accumulated.length; i++) {
const n = accumulated[i];
seen.set((n && n.public_key) || ('__nokey' + i), n);
}
// Enforce safetyCap as a real node-count ceiling (the page loop only bounds
// it to the next pageSize multiple), so e.g. live.js's LIVE_MAP_MAX_NODES is
// honored exactly rather than overshooting by up to pageSize-1.
const nodes = Array.from(seen.values()).slice(0, safetyCap);
return { nodes, counts, total: nodes.length };
}
function invalidateApiCache(prefix) {
for (const key of _apiCache.keys()) {
if (key.startsWith(prefix || '')) _apiCache.delete(key);
+26 -12
View File
@@ -152,16 +152,11 @@ async function load() {
document.getElementById('show-nodes').checked = false;
try {
const [areasResp, nodesResp] = await Promise.all([
fetch(`${baseUrl}/api/config/areas/polygons`),
fetch(`${baseUrl}/api/nodes?limit=9999`)
]);
const areasResp = await fetch(`${baseUrl}/api/config/areas/polygons`);
if (!areasResp.ok) throw new Error(`areas/polygons: ${areasResp.status}`);
if (!nodesResp.ok) throw new Error(`nodes: ${nodesResp.status}`);
areas = await areasResp.json();
const nodesData = await nodesResp.json();
allNodes = nodesData.nodes || nodesData || [];
allNodes = await fetchAllNodesPaged('');
buildSidebar();
buildNodeLayer();
@@ -229,19 +224,38 @@ function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Paginate past the server's per-request node cap (listLimits.nodesMax) so the
// full node set is surfaced. This page is embeddable cross-origin via baseUrl,
// so it can't use app.js's fetchAllNodes; the loop is inlined. extra: query
// fragment '&'-prefixed (e.g. '&area=x'). Throws on a non-OK response so callers
// surface the error instead of silently rendering a truncated, complete-looking set.
async function fetchAllNodesPaged(extra) {
const PAGE = 500, CAP = 10000, out = [];
for (let off = 0; off < CAP; off += PAGE) {
const r = await fetch(`${baseUrl}/api/nodes?limit=${PAGE}&offset=${off}${extra}`);
if (!r.ok) throw new Error(`/api/nodes ${r.status} at offset ${off}`);
const d = await r.json();
const page = Array.isArray(d) ? d : (d.nodes || []);
out.push.apply(out, page);
if (page.length < PAGE) break;
}
// Dedup by public_key; rows missing one get a unique key so they aren't collapsed.
const seen = new Map();
out.forEach((n, i) => seen.set((n && n.public_key) || ('__nokey' + i), n));
return Array.from(seen.values());
}
async function loadAreaNodes(areaKey, color, group) {
try {
const resp = await fetch(`${baseUrl}/api/nodes?area=${encodeURIComponent(areaKey)}&limit=9999`);
if (!resp.ok) return;
const data = await resp.json();
(data.nodes || data || []).forEach(n => {
const list = await fetchAllNodesPaged(`&area=${encodeURIComponent(areaKey)}`);
list.forEach(n => {
if (!n.lat || !n.lon) return;
const m = L.circleMarker([n.lat, n.lon], { radius: 7, color, fillColor: color, fillOpacity: 0.85, weight: 2 });
m.bindPopup(`<div class="node-popup"><strong>${escapeHtml(n.name || n.public_key?.slice(0,8) || '?')}</strong><br>
${escapeHtml(n.public_key?.slice(0,16) || '')}…<br>GPS: ${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}<br><em>${escapeHtml(areaKey)}</em></div>`);
group.addLayer(m);
});
} catch(_) {}
} catch (e) { console.error('loadAreaNodes failed for', areaKey, e); }
}
function buildNodeLayer() {
+10 -6
View File
@@ -2577,9 +2577,9 @@
// "Show all nodes" is enabled. Empty string when no region set.
const rqs = (window.RegionFilter && typeof RegionFilter.nodesRegionQueryString === 'function')
? RegionFilter.nodesRegionQueryString() : '';
const url = beforeTs
? `/api/nodes?limit=${window.LIVE_MAP_MAX_NODES}&before=${encodeURIComponent(new Date(beforeTs).toISOString())}${aqs}${rqs}`
: `/api/nodes?limit=${window.LIVE_MAP_MAX_NODES}${aqs}${rqs}`;
const beforeQs = beforeTs
? `&before=${encodeURIComponent(new Date(beforeTs).toISOString())}`
: '';
// Full reload (no beforeTs): clear existing markers so switching areas
// removes nodes that no longer belong to the selected area.
if (!beforeTs) {
@@ -2587,9 +2587,13 @@
nodeMarkers = {};
nodeData = {};
}
const resp = await fetch(url);
const nodes = await resp.json();
const list = Array.isArray(nodes) ? nodes : (nodes.nodes || []);
// Paginate past the server's per-request node cap (listLimits.nodesMax)
// so the live map isn't truncated to the top-N by advert. LIVE_MAP_MAX_NODES
// is passed as safetyCap, which fetchAllNodes enforces as a hard ceiling on
// returned nodes — so the live map never renders more than the configured max.
const { nodes: list } = await fetchAllNodes(`${beforeQs}${aqs}${rqs}`, {
safetyCap: window.LIVE_MAP_MAX_NODES || 10000,
});
var now = Date.now();
list.forEach(n => {
if (n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0)) {
+4 -1
View File
@@ -1216,7 +1216,10 @@
try { REGION_NAMES = await api('/config/regions', { ttl: 3600 }); } catch {}
const aqs = AreaFilter.areaQueryString();
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}${aqs}`, { ttl: CLIENT_TTL.nodeList });
// Paginate past the server's per-request node cap (listLimits.nodesMax)
// so actively-relaying repeaters that last advertised hours ago still
// appear instead of being truncated by the top-N window. See fetchAllNodes.
const data = await fetchAllNodes(`&lastHeard=${filters.lastHeard}${aqs}`, { ttl: CLIENT_TTL.nodeList });
nodes = data.nodes || [];
// Load observers for jump buttons + map markers
+6 -2
View File
@@ -903,7 +903,7 @@
if (!HopResolver.ready()) {
try {
const [nodeData, obsData, coordData] = await Promise.all([
api('/nodes?limit=2000', { ttl: 60000 }),
fetchAllNodes('', { ttl: 60000 }),
api('/observers', { ttl: 60000 }),
api('/iata-coords', { ttl: 300000 }).catch(() => ({ coords: {} })),
]);
@@ -911,7 +911,11 @@
observers: obsData.observers || obsData || [],
iataCoords: coordData.coords || {},
});
} catch {}
} catch (e) {
// Non-fatal: hops will render as unresolved hex prefixes until a later
// call succeeds. Log so a paginated /api/nodes failure isn't silent.
console.warn('[packets] HopResolver init failed:', e);
}
}
}
+1
View File
@@ -14,6 +14,7 @@ node test-packet-filter-ux.js
node test-aging.js
node test-issue-1065-gesture-hints-gates.js
node test-frontend-helpers.js
node test-fetch-all-nodes-pagination.js
node test-url-state.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
+247
View File
@@ -0,0 +1,247 @@
/* Regression test for the /api/nodes per-request row cap (#1606 class).
*
* Bug: /api/nodes clamps ?limit to listLimits.nodesMax (default 2000;
* originally a hard 500 in PR #1540, raised/made configurable in #1589) and
* orders by last_seen DESC. Every node-list consumer (map.js, live.js,
* analytics.js, packets.js, area-map.html) issued a single ?limit=N fetch and
* trusted the response as the full set, so a mesh with more nodes than the cap
* silently saw only the top rows. A repeater that relays constantly but last
* self-advertised hours ago fell outside that window and vanished from the
* map/live view even though it was plainly alive.
*
* Fix: a shared app.js `fetchAllNodes()` helper pages through /api/nodes (fixed
* client page size, well under the server cap) until a short page, deduping by
* public_key, and caps the result at safetyCap. This test drives the REAL
* api()+fetchAllNodes against a mocked fetch using a 500-per-page fixture (the
* client page size) past which a node is hidden, plus the server's unreliable
* per-page `total`. Pre-fix consumers saw one page; the helper surfaces all.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
const pending = [];
function test(name, fn) {
try {
const out = fn();
if (out && typeof out.then === 'function') {
pending.push(out.then(() => { passed++; console.log(' ✅ ' + name); })
.catch(e => { failed++; console.log(' ❌ ' + name + ': ' + e.message); }));
return;
}
passed++; console.log(' ✅ ' + name);
} catch (e) {
failed++; console.log(' ❌ ' + name + ': ' + e.message);
}
}
function makeSandbox() {
const ctx = {
window: {},
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
},
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError,
parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent,
setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => {}, clearInterval: () => {},
performance: { now: () => Date.now() },
localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(),
location: { hash: '' },
CustomEvent: class CustomEvent {},
Map, Set, Promise, URLSearchParams,
addEventListener: () => {}, dispatchEvent: () => {},
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
};
ctx.window.addEventListener = () => {};
ctx.window.dispatchEvent = () => {};
vm.createContext(ctx);
return ctx;
}
function loadInCtx(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
// A fetch mock that mirrors the real /api/nodes: clamps ?limit to `cap`,
// honors ?offset, and returns the same kind of unreliable `total` the server
// emits (clamped to the page size). `extraDup` injects one duplicate
// public_key straddling a page boundary to exercise dedup.
function makeNodesFetch(total, cap, opts = {}) {
const calls = [];
const fixture = [];
for (let i = 0; i < total; i++) fixture.push({ public_key: 'pk' + i, name: 'N' + i });
// Optionally repeat the last row of page 1 as the first row of page 2.
if (opts.dupAtBoundary) fixture[cap] = fixture[cap - 1];
return {
calls,
fetch: (url) => {
calls.push(url);
const qs = url.split('?')[1] || '';
const p = new URLSearchParams(qs);
const limit = Math.min(parseInt(p.get('limit') || '50', 10), cap);
const offset = parseInt(p.get('offset') || '0', 10);
const page = fixture.slice(offset, offset + limit);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ nodes: page, counts: { repeaters: total }, total: page.length }),
});
},
};
}
console.log('\n=== fetchAllNodes: pagination past the 500-row cap ===');
test('surfaces ALL nodes past the 500 server cap (1200 > 500)', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
const m = makeNodesFetch(1200, 500);
ctx.fetch = m.fetch;
const out = await ctx.fetchAllNodes('');
assert.strictEqual(out.nodes.length, 1200, 'expected all 1200 nodes, got ' + out.nodes.length);
assert.strictEqual(out.total, 1200, 'total must be the real deduped count, not the clamped per-page total');
assert.strictEqual(m.calls.length, 3, 'expected 3 pages (500+500+200), got ' + m.calls.length);
});
test('stops on a short page rather than the unreliable server total', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
// Exactly 1000 → pages 500, 500, then a 0-length page stops the loop.
const m = makeNodesFetch(1000, 500);
ctx.fetch = m.fetch;
const out = await ctx.fetchAllNodes('');
assert.strictEqual(out.nodes.length, 1000);
assert.strictEqual(m.calls.length, 3, 'expected one extra empty page after two full pages');
});
test('dedups a public_key repeated across a page boundary', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
const m = makeNodesFetch(1200, 500, { dupAtBoundary: true });
ctx.fetch = m.fetch;
const out = await ctx.fetchAllNodes('');
const keys = out.nodes.map(n => n.public_key);
assert.strictEqual(new Set(keys).size, keys.length, 'result must contain no duplicate public_key');
});
test('passes counts from the first page through', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
const m = makeNodesFetch(600, 500);
ctx.fetch = m.fetch;
const out = await ctx.fetchAllNodes('');
assert.strictEqual(out.counts.repeaters, 600);
});
test('appends extraQuery verbatim and caps at a 500-aligned safetyCap', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
const m = makeNodesFetch(5000, 500);
ctx.fetch = m.fetch;
const out = await ctx.fetchAllNodes('&lastHeard=30d&area=BE', { safetyCap: 1000 });
// safetyCap=1000 → offsets 0 and 500 → exactly 1000 nodes (tightened so an
// off-by-pageSize regression is caught here, not in production).
assert.strictEqual(out.nodes.length, 1000, 'expected exactly 1000 nodes, got ' + out.nodes.length);
assert.ok(m.calls[0].indexOf('&lastHeard=30d&area=BE') !== -1, 'extraQuery must be appended to the request');
assert.ok(m.calls[0].indexOf('limit=500') !== -1 && m.calls[0].indexOf('offset=0') !== -1, 'first page must request limit/offset');
});
test('safetyCap is a hard node-count ceiling (no pageSize overshoot)', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
const m = makeNodesFetch(5000, 500);
ctx.fetch = m.fetch;
// safetyCap=800 is NOT a multiple of pageSize: the loop fetches offsets 0 and
// 500 (1000 rows) but the result must be sliced back to exactly 800 — this is
// the live.js LIVE_MAP_MAX_NODES ceiling that previously overshot.
const out = await ctx.fetchAllNodes('', { safetyCap: 800 });
assert.strictEqual(out.nodes.length, 800, 'safetyCap must cap returned nodes exactly, got ' + out.nodes.length);
assert.strictEqual(out.total, 800, 'total must equal the capped count');
});
test('rows missing public_key are NOT collapsed into one', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
// Two distinct rows both lacking public_key must survive as two entries.
ctx.fetch = () => Promise.resolve({
ok: true,
json: () => Promise.resolve({ nodes: [{ name: 'A' }, { name: 'B' }, { public_key: 'pk1', name: 'C' }] }),
});
const out = await ctx.fetchAllNodes('');
assert.strictEqual(out.nodes.length, 3, 'falsy-key rows must not collapse, got ' + out.nodes.length);
});
test('empty result → exactly one request, zero nodes', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
let calls = 0;
ctx.fetch = () => { calls++; return Promise.resolve({ ok: true, json: () => Promise.resolve({ nodes: [], counts: {}, total: 0 }) }); };
const out = await ctx.fetchAllNodes('');
assert.strictEqual(out.nodes.length, 0);
assert.strictEqual(calls, 1, 'a 0-length first page must stop after one request, got ' + calls);
});
test('handles a bare-array /api/nodes response shape', async () => {
const ctx = makeSandbox();
loadInCtx(ctx, 'public/app.js');
ctx.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve([{ public_key: 'a' }, { public_key: 'b' }]) });
const out = await ctx.fetchAllNodes('');
assert.strictEqual(out.nodes.length, 2, 'bare-array body must be accepted');
});
// ===== area-map.html inline fetchAllNodesPaged (separate impl, can't import app.js) =====
// area-map.html is embeddable cross-origin, so it carries its own copy of the
// loop. Extract that function from the HTML and exercise the SAME behaviors plus
// its distinct error contract (throws on a non-OK page rather than returning a
// silent partial). Keeping this test next to the helper's prevents the two
// copies from drifting unnoticed.
function loadAreaMapHelper(fetchImpl) {
const html = fs.readFileSync('public/area-map.html', 'utf8');
const start = html.indexOf('async function fetchAllNodesPaged');
assert(start !== -1, 'fetchAllNodesPaged not found in area-map.html');
let depth = 0, end = -1;
for (let i = html.indexOf('{', start); i < html.length; i++) {
if (html[i] === '{') depth++;
else if (html[i] === '}' && --depth === 0) { end = i + 1; break; }
}
const src = html.slice(start, end);
// Provide fetch + baseUrl as closure params (the function references both).
return new Function('fetch', 'baseUrl', src + '\nreturn fetchAllNodesPaged;')(fetchImpl, 'http://host');
}
test('area-map inline helper paginates past the cap and dedups', async () => {
const m = makeNodesFetch(1200, 500, { dupAtBoundary: true });
const fetchAllNodesPaged = loadAreaMapHelper(m.fetch);
const list = await fetchAllNodesPaged('');
const keys = list.map(n => n.public_key);
assert.strictEqual(new Set(keys).size, keys.length, 'no duplicate public_key');
assert.ok(list.length >= 1199, 'expected ~1200 nodes past the cap, got ' + list.length);
});
test('area-map inline helper throws on a non-OK page (no silent partial)', async () => {
const fixture500 = [];
for (let i = 0; i < 500; i++) fixture500.push({ public_key: 'q' + i });
const fetchImpl = (url) => {
const off = parseInt(new URLSearchParams(url.split('?')[1] || '').get('offset') || '0', 10);
if (off === 0) return Promise.resolve({ ok: true, json: () => Promise.resolve({ nodes: fixture500 }) });
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) });
};
const fetchAllNodesPaged = loadAreaMapHelper(fetchImpl);
let threw = false;
try { await fetchAllNodesPaged(''); } catch (_) { threw = true; }
assert.ok(threw, 'a non-OK page must reject, not return a truncated complete-looking list');
});
(async () => {
await Promise.all(pending);
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();
+153
View File
@@ -0,0 +1,153 @@
/**
* E2E: the Map view must paginate /api/nodes past the server's per-request cap.
*
* Bug (this PR): map.js issued a single `/api/nodes?limit=10000` fetch. The
* server clamps ?limit to listLimits.nodesMax (default 2000; originally 500 in
* PR #1540) and orders by last_seen DESC, so on a mesh larger than the cap the
* map silently dropped every node whose last self-advert fell outside the top
* window even ones relaying constantly. The node still appeared in the
* (paginated, #1606) Nodes list but vanished from the map.
*
* This test mocks /api/nodes with a 500-per-page cap (the client page size) and
* a 501st node ("PAGE2 RP") reachable only on the second page. After the map
* loads, the app's node set (window.__mc_nodes, populated by map.js loadNodes()
* fetchAllNodes()) must contain all 501 nodes including the page-2 node, and a
* marker for it must exist on the map. Pre-fix, __mc_nodes would hold 500 and
* the page-2 node would be absent.
*
* Backend-independent: every /api/* call the map makes at load is mocked via
* page.route, so it runs against any static host at BASE_URL (the CI fixture
* server, or a plain static server locally).
*
* Run: BASE_URL=http://localhost:13581 node test-map-nodes-pagination-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const PAGE_CAP = 500; // client page size; a node sits past it on page 2
const PAGE2_KEY = 'page2deadbeef00000000000000000000000000000000000000000000000beef02';
const PAGE2_NAME = 'PAGE2 RP';
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'); }
// Build the full fixture: 500 "page 1" repeaters + 1 page-2 repeater.
function buildFixture() {
const nodes = [];
for (let i = 0; i < PAGE_CAP; i++) {
nodes.push({
public_key: 'p1' + String(i).padStart(62, '0'),
name: 'P1-' + i,
role: 'repeater',
lat: 51 + (i % 100) * 0.001,
lon: 5 + Math.floor(i / 100) * 0.001,
last_seen: '2026-06-09T07:40:00Z',
hash_size: 1,
});
}
nodes.push({
public_key: PAGE2_KEY,
name: PAGE2_NAME,
role: 'repeater',
lat: 50.5,
lon: 4.5,
last_seen: '2026-06-08T13:55:14Z', // older advert → would be cut by the 500 cap
hash_size: 2,
});
return nodes;
}
(async () => {
const launchOpts = {
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
};
const browser = await chromium.launch(launchOpts);
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== Map /api/nodes pagination E2E against ${BASE} ===`);
const fixture = buildFixture();
let nodesRequests = 0;
// Mock every /api/* call the map makes at load. /api/nodes?... is paginated
// with the same 500-row clamp + offset semantics as the real server; all
// other endpoints get harmless stubs so the test is backend-independent.
await page.route('**/api/**', (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/api/nodes') {
nodesRequests++;
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), PAGE_CAP);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const slice = fixture.slice(offset, offset + limit);
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
nodes: slice,
total: slice.length, // deliberately wrong per-page total; the helper must ignore it
counts: { repeaters: fixture.length, rooms: 0, companions: 0, sensors: 0 },
}),
});
}
if (path === '/api/observers') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ observers: [] }) });
}
// Generic stub for config/regions/map/etc. — empty object is safe.
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
await page.waitForSelector('#leaflet-map', { timeout: 15000 });
await step('map loads all 501 nodes by paginating past the 500-row cap', async () => {
// Wait until loadNodes() has populated the app node set.
await page.waitForFunction(
() => Array.isArray(window.__mc_nodes) && window.__mc_nodes.length >= 501,
{ timeout: 15000 }
);
const len = await page.evaluate(() => window.__mc_nodes.length);
assert(len === 501, 'expected 501 nodes in __mc_nodes, got ' + len);
assert(nodesRequests >= 2, 'expected ≥2 /api/nodes page requests, got ' + nodesRequests);
});
await step('the page-2 node (cut by the cap pre-fix) is present in the node set', async () => {
const found = await page.evaluate(
(key) => window.__mc_nodes.some((n) => n.public_key === key),
PAGE2_KEY
);
assert(found, 'page-2 node ' + PAGE2_NAME + ' missing from __mc_nodes');
});
await step('a marker for the page-2 node is rendered on the map', async () => {
const hasMarker = await page.evaluate((key) => {
let found = false;
const scan = (layer) => {
if (found || !layer || !layer.eachLayer) return;
layer.eachLayer((m) => {
if (found) return;
if (m._nodeKey === key) { found = true; return; }
if (m.eachLayer) scan(m); // cluster groups nest their markers
});
};
// markerLayer + clusterGroup are internal; reach them via the map's layers.
if (window.__mc_map && window.__mc_map.eachLayer) window.__mc_map.eachLayer(scan);
return found;
}, PAGE2_KEY);
assert(hasMarker, 'no marker with _nodeKey for the page-2 node was rendered');
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(1); });