mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:07:53 +00:00
## Summary Fixes #1606 — frontend `public/nodes.js` issued a single `?limit=5000` fetch to `/api/nodes` and trusted the response as the complete node set. After PR #1540 (v3.8.3) clamped `/api/nodes` `?limit` to 500 as a DoS guard, that single fetch silently truncated to the top 500 rows by `last_seen DESC`. On the reporter's 2313-node deployment, **78% of nodes (1813) were invisible** in the Nodes page, with no UI indication anything was missing. Replaces the single fetch in `loadNodes()` with a pagination loop driven by `data.total` from the first response. Stops when `_allNodes.length >= total`, when the server returns a short page, or at a 10 000-row safety cap. `counts` is taken from the first response and refreshed on each subsequent page (last writer wins; the server returns the same `counts` payload each call). Scope is deliberately narrow per the (munger) finding in the triage comment: the three sibling call sites (`analytics.js:2080,2817`, `packets.js:791`) are **NOT** touched here. They get their own follow-up. ## Repro ```bash curl -s "https://analyzer.marwoj.net/api/nodes?limit=5000" | jq '{nodes_len: (.nodes | length), total}' # Before fix on >500-node deployment: # { "nodes_len": 500, "total": 2313 } ← frontend silently displays only 500 ``` ## Before / after evidence Unit test `test-issue-1606-pagination.js` drives `loadNodes()` against a mocked `api()` exposing 1200 fixture nodes with a 500-per-page server cap (mirrors the real `/api/nodes` clamp). | | `_allNodes.length` | `data.total` | |---|---:|---:| | Before (single fetch) | **500** | 1200 | | After (pagination loop) | **1200** | 1200 | Red commit: `700a5cc4` (test asserts `_allNodes.length === data.total`, fails 500 ≠ 1200). Green commit: `6d51da45` (pagination loop, test passes). All 611 tests in `test-frontend-helpers.js` continue to pass — the existing nodes.js WS-handler runtime tests are unaffected. ## Browser verified Mocked-API unit test only — staging currently has <500 nodes so the bug isn't reproducible there. The reporter's deployment (`analyzer.marwoj.net`, 2313 nodes) is where the visible regression occurs. The unit test reproduces the exact failure mode against a controllable fixture. ## E2E assertion added `test-issue-1606-pagination.js:170` — `assert.strictEqual(all.length, env.fixtureTotal, ...)` ## Files changed - `public/nodes.js` — `loadNodes()` single fetch → pagination loop - `test-issue-1606-pagination.js` — new regression test (sandboxed nodes.js + mock api) ## Out of scope (deferred to follow-up) Per triage's (munger) note, these three siblings have the same single-fetch bug and need their own focused PR: - `public/analytics.js:2080` (`limit=10000`) - `public/analytics.js:2817` (`limit=10000`) - `public/packets.js:791` (`limit=2000`) Closes #1606 --------- Co-authored-by: openclaw-bot <bot@openclaw.local>
394 lines
17 KiB
JavaScript
394 lines
17 KiB
JavaScript
/* Regression test for issue #1606 — frontend nodes.js must paginate /api/nodes.
|
|
*
|
|
* Bug: v3.8.3 clamped /api/nodes ?limit to 500. public/nodes.js:1117 still
|
|
* hard-codes limit=5000 and treats the single response as the full set, so
|
|
* deployments with >500 nodes silently see only the top 500 by last_seen DESC.
|
|
*
|
|
* This test drives loadNodes() against a mocked api() that exposes a fixture
|
|
* of 1200 nodes total, returning at most 500 per call (the new server cap).
|
|
* After loadNodes() completes, _allNodes.length must equal data.total (1200).
|
|
*
|
|
* With the pre-fix single-fetch code, _allNodes.length == 500 and the
|
|
* assertion below fails. With the pagination loop in place, it passes.
|
|
*/
|
|
'use strict';
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
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 loadInCtx(ctx, file) {
|
|
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
|
|
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
|
}
|
|
|
|
function makeSandbox() {
|
|
const ctx = {
|
|
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
|
document: {
|
|
readyState: 'complete',
|
|
createElement: () => ({ id: '', textContent: '', innerHTML: '', style: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){return false;} }, appendChild(){}, addEventListener(){} }),
|
|
head: { appendChild: () => {} },
|
|
getElementById: () => null,
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
querySelectorAll: () => [],
|
|
querySelector: () => null,
|
|
},
|
|
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError,
|
|
parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent,
|
|
setTimeout: (fn) => { fn(); return 0; }, clearTimeout: () => {},
|
|
setInterval: () => 0, clearInterval: () => {},
|
|
Promise, Map, Set, URLSearchParams,
|
|
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
|
performance: { now: () => Date.now() },
|
|
localStorage: (() => {
|
|
const store = {};
|
|
return { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
|
|
})(),
|
|
location: { hash: '' },
|
|
getHashParams: function () { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); },
|
|
CustomEvent: class CustomEvent {},
|
|
};
|
|
vm.createContext(ctx);
|
|
return ctx;
|
|
}
|
|
|
|
function makeNodesEnv(totalNodes, serverCap) {
|
|
const ctx = makeSandbox();
|
|
const domElements = {};
|
|
function getEl(id) {
|
|
if (!domElements[id]) {
|
|
domElements[id] = {
|
|
id, innerHTML: '', textContent: '', value: '', scrollTop: 0,
|
|
style: {}, dataset: {},
|
|
classList: { add(){}, remove(){}, toggle(){}, contains(){return false;} },
|
|
addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; },
|
|
getAttribute() { return null; }, setAttribute() {}, appendChild() {},
|
|
};
|
|
}
|
|
return domElements[id];
|
|
}
|
|
ctx.document.getElementById = getEl;
|
|
|
|
// Fixture: `totalNodes` distinct nodes; api() returns at most `serverCap`
|
|
// per request, honoring `offset` and `limit` query params.
|
|
const fixture = [];
|
|
for (let i = 0; i < totalNodes; i++) {
|
|
fixture.push({
|
|
public_key: i.toString(16).padStart(62, '0') + 'a0',
|
|
name: 'Node' + i,
|
|
role: 'repeater',
|
|
advert_count: 1,
|
|
last_seen: new Date(Date.now() - i * 1000).toISOString(),
|
|
});
|
|
}
|
|
const apiCalls = [];
|
|
ctx.api = function (url) {
|
|
apiCalls.push(url);
|
|
const q = url.indexOf('?') >= 0 ? url.slice(url.indexOf('?') + 1) : '';
|
|
const params = new URLSearchParams(q);
|
|
const offset = parseInt(params.get('offset') || '0', 10);
|
|
const reqLimit = parseInt(params.get('limit') || '500', 10);
|
|
const limit = Math.min(reqLimit, serverCap);
|
|
const page = fixture.slice(offset, offset + limit);
|
|
return Promise.resolve({
|
|
nodes: page,
|
|
total: fixture.length,
|
|
counts: { repeaters: fixture.length },
|
|
});
|
|
};
|
|
ctx.invalidateApiCache = () => {};
|
|
|
|
// Stubs that nodes.js touches at module load and during loadNodes
|
|
ctx.ROLE_COLORS = { repeater: '#0', room: '#0', companion: '#0', sensor: '#0' };
|
|
ctx.ROLE_STYLE = {};
|
|
ctx.TYPE_COLORS = {};
|
|
ctx.getNodeStatus = () => 'active';
|
|
ctx.getHealthThresholds = () => ({ staleMs: 1, degradedMs: 1, silentMs: 1 });
|
|
ctx.timeAgo = () => '';
|
|
ctx.truncate = (s) => s;
|
|
ctx.escapeHtml = (s) => String(s || '');
|
|
ctx.payloadTypeName = () => '';
|
|
ctx.payloadTypeColor = () => '';
|
|
ctx.debounce = (fn) => fn;
|
|
ctx.initTabBar = () => {};
|
|
ctx.getFavorites = () => [];
|
|
ctx.favStar = () => '';
|
|
ctx.bindFavStars = () => {};
|
|
ctx.makeColumnsResizable = () => {};
|
|
ctx.CLIENT_TTL = { nodeList: 0, nodeDetail: 0, nodeHealth: 0 };
|
|
ctx.RegionFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getRegionParam(){ return ''; } };
|
|
ctx.AreaFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getAreaParam(){ return ''; } };
|
|
ctx.getFleetSkew = () => Promise.resolve({});
|
|
ctx.onWS = () => {};
|
|
ctx.offWS = () => {};
|
|
ctx.debouncedOnWS = () => () => {};
|
|
let pageMod = null;
|
|
ctx.registerPage = (name, handlers) => { pageMod = handlers; };
|
|
|
|
const repoRoot = path.resolve(__dirname);
|
|
loadInCtx(ctx, path.join(repoRoot, 'public/nodes.js'));
|
|
|
|
return { ctx, pageMod: () => pageMod, apiCalls, fixtureTotal: fixture.length };
|
|
}
|
|
|
|
console.log('=== issue #1606: nodes.js pagination ===');
|
|
|
|
test('loadNodes paginates and loads all 1200 nodes when server caps at 500', async () => {
|
|
const env = makeNodesEnv(1200, 500);
|
|
// pageMod.init() calls loadNodes() internally. Await one tick of microtasks
|
|
// by reaching into the test hook and waiting on a fresh fetch.
|
|
const appEl = env.ctx.document.getElementById('page');
|
|
env.pageMod().init(appEl);
|
|
// Give all queued promises a chance to settle. loadNodes is async; the init
|
|
// path kicks it off but doesn't return its promise, so we loop until the
|
|
// mocked api stops being called or a generous cap is hit.
|
|
let lastCount = -1, stable = 0;
|
|
for (let i = 0; i < 50; i++) {
|
|
await new Promise(r => setImmediate(r));
|
|
const n = env.ctx.window._nodesGetAllNodes();
|
|
const cur = Array.isArray(n) ? n.length : -1;
|
|
if (cur === lastCount) { stable++; if (stable > 3) break; } else { stable = 0; lastCount = cur; }
|
|
}
|
|
const all = env.ctx.window._nodesGetAllNodes();
|
|
assert.ok(Array.isArray(all), '_allNodes must be an array, got ' + typeof all);
|
|
assert.strictEqual(all.length, env.fixtureTotal,
|
|
'expected _allNodes.length === total (' + env.fixtureTotal + '), got ' + all.length +
|
|
' — frontend silently truncated to server cap (issue #1606)');
|
|
});
|
|
|
|
// --- B1: partial-load deadlock (api throws on page 2) ---
|
|
test('B1: after api() throws mid-pagination, _allNodes resets so next call refetches', async () => {
|
|
const ctx = makeSandbox();
|
|
const domElements = {};
|
|
function getEl(id) {
|
|
if (!domElements[id]) {
|
|
domElements[id] = {
|
|
id, innerHTML: '', textContent: '', value: '', scrollTop: 0,
|
|
style: {}, dataset: {},
|
|
classList: { add(){}, remove(){}, toggle(){}, contains(){return false;} },
|
|
addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; },
|
|
getAttribute() { return null; }, setAttribute() {}, appendChild() {},
|
|
};
|
|
}
|
|
return domElements[id];
|
|
}
|
|
ctx.document.getElementById = getEl;
|
|
|
|
const fixture = [];
|
|
for (let i = 0; i < 1200; i++) {
|
|
fixture.push({ public_key: i.toString(16).padStart(62, '0') + 'b0', name: 'N' + i, role: 'repeater', advert_count: 1, last_seen: new Date().toISOString() });
|
|
}
|
|
let callCount = 0;
|
|
let shouldThrow = true;
|
|
ctx.api = function(url) {
|
|
callCount++;
|
|
const params = new URLSearchParams(url.split('?')[1] || '');
|
|
const offset = parseInt(params.get('offset') || '0', 10);
|
|
if (shouldThrow && offset >= 500) return Promise.reject(new Error('network error on page 2'));
|
|
const page = fixture.slice(offset, offset + 500);
|
|
return Promise.resolve({ nodes: page, total: 1200, counts: { repeaters: 1200 } });
|
|
};
|
|
ctx.invalidateApiCache = () => {};
|
|
ctx.ROLE_COLORS = { repeater: '#0', room: '#0', companion: '#0', sensor: '#0' };
|
|
ctx.ROLE_STYLE = {};
|
|
ctx.TYPE_COLORS = {};
|
|
ctx.getNodeStatus = () => 'active';
|
|
ctx.getHealthThresholds = () => ({ staleMs: 1, degradedMs: 1, silentMs: 1 });
|
|
ctx.timeAgo = () => '';
|
|
ctx.truncate = (s) => s;
|
|
ctx.escapeHtml = (s) => String(s || '');
|
|
ctx.payloadTypeName = () => '';
|
|
ctx.payloadTypeColor = () => '';
|
|
ctx.debounce = (fn) => fn;
|
|
ctx.initTabBar = () => {};
|
|
ctx.getFavorites = () => [];
|
|
ctx.favStar = () => '';
|
|
ctx.bindFavStars = () => {};
|
|
ctx.makeColumnsResizable = () => {};
|
|
ctx.CLIENT_TTL = { nodeList: 0, nodeDetail: 0, nodeHealth: 0 };
|
|
ctx.RegionFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getRegionParam(){ return ''; } };
|
|
ctx.AreaFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getAreaParam(){ return ''; } };
|
|
ctx.getFleetSkew = () => Promise.resolve({});
|
|
ctx.onWS = () => {};
|
|
ctx.offWS = () => {};
|
|
ctx.debouncedOnWS = () => () => {};
|
|
let pageMod = null;
|
|
ctx.registerPage = (name, handlers) => { pageMod = handlers; };
|
|
loadInCtx(ctx, path.join(__dirname, 'public/nodes.js'));
|
|
|
|
// First call — will fail on page 2
|
|
pageMod.init(ctx.document.getElementById('page'));
|
|
for (let i = 0; i < 50; i++) await new Promise(r => setImmediate(r));
|
|
|
|
// _allNodes must be null/undefined after failure (not [] which is truthy and blocks refetch)
|
|
const afterFail = ctx.window._nodesGetAllNodes();
|
|
assert.ok(!afterFail || afterFail === null,
|
|
'B1: after api() throws mid-pagination, _allNodes should be null/falsy so next call refetches, got: ' + JSON.stringify(afterFail && afterFail.length));
|
|
});
|
|
|
|
// --- M1: total instability — loop must use length-based exit, not total ---
|
|
test('M1: pagination fetches all pages even when total is understated (filter instability)', async () => {
|
|
const ctx = makeSandbox();
|
|
const domElements = {};
|
|
function getEl(id) {
|
|
if (!domElements[id]) {
|
|
domElements[id] = {
|
|
id, innerHTML: '', textContent: '', value: '', scrollTop: 0,
|
|
style: {}, dataset: {},
|
|
classList: { add(){}, remove(){}, toggle(){}, contains(){return false;} },
|
|
addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; },
|
|
getAttribute() { return null; }, setAttribute() {}, appendChild() {},
|
|
};
|
|
}
|
|
return domElements[id];
|
|
}
|
|
ctx.document.getElementById = getEl;
|
|
|
|
// Server reports total:50 (filtered count) but actually sends full pages
|
|
const fixture = [];
|
|
for (let i = 0; i < 1200; i++) {
|
|
fixture.push({ public_key: i.toString(16).padStart(62, '0') + 'c0', name: 'M' + i, role: 'repeater', advert_count: 1, last_seen: new Date().toISOString() });
|
|
}
|
|
ctx.api = function(url) {
|
|
const params = new URLSearchParams(url.split('?')[1] || '');
|
|
const offset = parseInt(params.get('offset') || '0', 10);
|
|
const page = fixture.slice(offset, offset + 500);
|
|
// total is deliberately wrong (mimics routes.go:1357 area filter overwrite)
|
|
return Promise.resolve({ nodes: page, total: 50, counts: { repeaters: 50 } });
|
|
};
|
|
ctx.invalidateApiCache = () => {};
|
|
ctx.ROLE_COLORS = { repeater: '#0', room: '#0', companion: '#0', sensor: '#0' };
|
|
ctx.ROLE_STYLE = {};
|
|
ctx.TYPE_COLORS = {};
|
|
ctx.getNodeStatus = () => 'active';
|
|
ctx.getHealthThresholds = () => ({ staleMs: 1, degradedMs: 1, silentMs: 1 });
|
|
ctx.timeAgo = () => '';
|
|
ctx.truncate = (s) => s;
|
|
ctx.escapeHtml = (s) => String(s || '');
|
|
ctx.payloadTypeName = () => '';
|
|
ctx.payloadTypeColor = () => '';
|
|
ctx.debounce = (fn) => fn;
|
|
ctx.initTabBar = () => {};
|
|
ctx.getFavorites = () => [];
|
|
ctx.favStar = () => '';
|
|
ctx.bindFavStars = () => {};
|
|
ctx.makeColumnsResizable = () => {};
|
|
ctx.CLIENT_TTL = { nodeList: 0, nodeDetail: 0, nodeHealth: 0 };
|
|
ctx.RegionFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getRegionParam(){ return ''; } };
|
|
ctx.AreaFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getAreaParam(){ return ''; } };
|
|
ctx.getFleetSkew = () => Promise.resolve({});
|
|
ctx.onWS = () => {};
|
|
ctx.offWS = () => {};
|
|
ctx.debouncedOnWS = () => () => {};
|
|
let pageMod = null;
|
|
ctx.registerPage = (name, handlers) => { pageMod = handlers; };
|
|
loadInCtx(ctx, path.join(__dirname, 'public/nodes.js'));
|
|
|
|
pageMod.init(ctx.document.getElementById('page'));
|
|
for (let i = 0; i < 50; i++) await new Promise(r => setImmediate(r));
|
|
|
|
const all = ctx.window._nodesGetAllNodes();
|
|
assert.ok(Array.isArray(all), 'M1: _allNodes should be array');
|
|
// With total-driven loop (current code), it stops at 50 nodes. Correct behavior: fetch all 1200.
|
|
assert.strictEqual(all.length, 1200,
|
|
'M1: expected 1200 nodes (length-based exit) but got ' + all.length + ' (total-driven exit stopped early)');
|
|
});
|
|
|
|
// --- M2: progress feedback during multi-page load ---
|
|
test('M2: progress feedback is shown between page fetches', async () => {
|
|
const ctx = makeSandbox();
|
|
const domElements = {};
|
|
const domUpdates = [];
|
|
function getEl(id) {
|
|
if (!domElements[id]) {
|
|
domElements[id] = {
|
|
id, innerHTML: '', textContent: '', value: '', scrollTop: 0,
|
|
style: {}, dataset: {},
|
|
classList: { add(){}, remove(){}, toggle(){}, contains(){return false;} },
|
|
addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; },
|
|
getAttribute() { return null; }, setAttribute() {}, appendChild() {},
|
|
};
|
|
// Track innerHTML/textContent changes for progress detection
|
|
const orig = domElements[id];
|
|
Object.defineProperty(orig, 'innerHTML', {
|
|
get() { return orig._html || ''; },
|
|
set(v) { orig._html = v; if (id === 'nodesBody' && v && v.includes('Loading')) domUpdates.push(v); }
|
|
});
|
|
Object.defineProperty(orig, 'textContent', {
|
|
get() { return orig._text || ''; },
|
|
set(v) { orig._text = v; if (id === 'nodesBody' && v && v.includes('Loading')) domUpdates.push(v); }
|
|
});
|
|
}
|
|
return domElements[id];
|
|
}
|
|
ctx.document.getElementById = getEl;
|
|
|
|
const fixture = [];
|
|
for (let i = 0; i < 1200; i++) {
|
|
fixture.push({ public_key: i.toString(16).padStart(62, '0') + 'd0', name: 'P' + i, role: 'repeater', advert_count: 1, last_seen: new Date().toISOString() });
|
|
}
|
|
ctx.api = function(url) {
|
|
const params = new URLSearchParams(url.split('?')[1] || '');
|
|
const offset = parseInt(params.get('offset') || '0', 10);
|
|
const page = fixture.slice(offset, offset + 500);
|
|
return Promise.resolve({ nodes: page, total: 1200, counts: { repeaters: 1200 } });
|
|
};
|
|
ctx.invalidateApiCache = () => {};
|
|
ctx.ROLE_COLORS = { repeater: '#0', room: '#0', companion: '#0', sensor: '#0' };
|
|
ctx.ROLE_STYLE = {};
|
|
ctx.TYPE_COLORS = {};
|
|
ctx.getNodeStatus = () => 'active';
|
|
ctx.getHealthThresholds = () => ({ staleMs: 1, degradedMs: 1, silentMs: 1 });
|
|
ctx.timeAgo = () => '';
|
|
ctx.truncate = (s) => s;
|
|
ctx.escapeHtml = (s) => String(s || '');
|
|
ctx.payloadTypeName = () => '';
|
|
ctx.payloadTypeColor = () => '';
|
|
ctx.debounce = (fn) => fn;
|
|
ctx.initTabBar = () => {};
|
|
ctx.getFavorites = () => [];
|
|
ctx.favStar = () => '';
|
|
ctx.bindFavStars = () => {};
|
|
ctx.makeColumnsResizable = () => {};
|
|
ctx.CLIENT_TTL = { nodeList: 0, nodeDetail: 0, nodeHealth: 0 };
|
|
ctx.RegionFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getRegionParam(){ return ''; } };
|
|
ctx.AreaFilter = { init(){}, onChange(){ return () => {}; }, offChange(){}, getAreaParam(){ return ''; } };
|
|
ctx.getFleetSkew = () => Promise.resolve({});
|
|
ctx.onWS = () => {};
|
|
ctx.offWS = () => {};
|
|
ctx.debouncedOnWS = () => () => {};
|
|
let pageMod = null;
|
|
ctx.registerPage = (name, handlers) => { pageMod = handlers; };
|
|
loadInCtx(ctx, path.join(__dirname, 'public/nodes.js'));
|
|
|
|
pageMod.init(ctx.document.getElementById('page'));
|
|
for (let i = 0; i < 50; i++) await new Promise(r => setImmediate(r));
|
|
|
|
// At least one intermediate progress update should have been written to DOM
|
|
assert.ok(domUpdates.length > 0,
|
|
'M2: expected progress feedback in #nodesBody during pagination, got 0 updates');
|
|
});
|
|
|
|
Promise.allSettled(pending).then(() => {
|
|
console.log('\n Issue #1606: ' + passed + ' passed, ' + failed + ' failed\n');
|
|
if (failed > 0) process.exit(1);
|
|
});
|