diff --git a/public/analytics.js b/public/analytics.js
index 78a9bac73..b413ba0c3 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -2587,7 +2587,31 @@
}
}
+ // #560 — mirrors the role set and pluralized keys of GetAllRoleCounts
+ // (cmd/server/db.go), but tallies the rows the caller already loaded
+ // instead of querying the whole `nodes` table, so the breakdown describes
+ // the same region/area-filtered population as the Network Status counts
+ // rendered above it. Single bounded O(n) pass; no network call. Roles
+ // outside the known set (including "observer" and missing/unrecognized
+ // values) are not tallied, matching GetAllRoleCounts' existing omission of
+ // them from the breakdown today.
+ const NODE_ROLE_BREAKDOWN_KEYS = { repeater: 'repeaters', room: 'rooms', companion: 'companions', sensor: 'sensors' };
+ function nodeRoleBreakdownCounts(nodes) {
+ const counts = { repeaters: 0, rooms: 0, companions: 0, sensors: 0 };
+ (nodes || []).forEach(n => {
+ const key = NODE_ROLE_BREAKDOWN_KEYS[n && n.role];
+ if (key) counts[key]++;
+ });
+ return counts;
+ }
+
+ // Bumped on every call and checked after the await below so a late
+ // response from a superseded filter/tab change can never overwrite a
+ // newer render (mirrors the _channelRenderGen / _distanceGen pattern
+ // already used elsewhere in this file).
+ let _nodesTabGen = 0;
async function renderNodesTab(el) {
+ const myGen = ++_nodesTabGen;
el.innerHTML = '
Network Status
@@ -2742,6 +2778,7 @@
`;
} catch (e) {
+ if (myGen !== _nodesTabGen) return;
el.innerHTML = `
Failed to load node analytics: ${esc(e.message)}
`;
}
}
@@ -3403,6 +3440,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopScopeAudit(
window._analyticsHopDepthTimeSeriesChartHtml = hopDepthTimeSeriesChartHtml;
window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey;
window._analyticsBridgeRepeaterPubkeySet = bridgeRepeaterPubkeySet;
+ window._analyticsNodeRoleBreakdownCounts = nodeRoleBreakdownCounts;
+ window._analyticsRenderNodesTab = renderNodesTab;
}
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
diff --git a/public/app.js b/public/app.js
index 015ae2c9e..7084d0e04 100644
--- a/public/app.js
+++ b/public/app.js
@@ -411,11 +411,19 @@ function _warmupNotify_1659(active) {
// (e.g. '&lastHeard=30d&area=x', '&sortBy=lastSeen®ion=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`).
+// Returns { nodes, counts, total, truncated }: counts is from the first page;
+// total is the real deduped/capped node count (NOT the server's per-query
+// `total`). truncated is true when `nodes` may not be the complete
+// population for the query — either the page loop never saw a short page
+// before hitting safetyCap (so more rows may exist beyond it), or dedup
+// left more unique rows than safetyCap and the final slice below dropped
+// some. A caller that presents `nodes` as a complete population (e.g.
+// analytics.js's Role Breakdown, #560) must check this rather than
+// silently showing a partial count as whole.
async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyCap = 10000 } = {}) {
const accumulated = [];
let counts = {};
+ let stoppedShort = false;
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
@@ -426,7 +434,7 @@ async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyC
// 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;
+ if (page.length < pageSize) { stoppedShort = true; 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
@@ -440,7 +448,8 @@ async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyC
// 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 };
+ const truncated = !stoppedShort || seen.size > nodes.length;
+ return { nodes, counts, total: nodes.length, truncated };
}
function invalidateApiCache(prefix) {
diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json
index cc74bdc79..59e1831b1 100644
--- a/scripts/e2e-shards.json
+++ b/scripts/e2e-shards.json
@@ -33,6 +33,14 @@
"meanSeconds": 0,
"measurementNote": "New suite; fresh and SPA ?region= links across five views, listener counts, back/forward, the hash ?region= after a control change on six views, and ?region=All. About 2.5 minutes locally; no CI duration measured yet."
},
+ {
+ "file": "test-issue-560-role-breakdown-e2e.js",
+ "shard": 2,
+ "env": { "BASE_URL": "$BASE_URL", "CORESCOPE_SERVER_BIN": "./corescope-server", "CORESCOPE_MIGRATE_BIN": "./corescope-migrate" },
+ "output": "e2e",
+ "meanSeconds": 0,
+ "measurementNote": "New suite; #560 Analytics -> Nodes Role Breakdown computed from the filtered rows, not the server's global /api/nodes counts -- an MRY region filter's role-breakdown sum must equal the Network Status total, and must differ from the unfiltered repeaters count; also an unfiltered-vs-server counts-contract check, and (round-2 review) an area-only and combined region+area check against a private, disposable server instance with one test-only area configured via -config-dir, since the shared BASE_URL server/fixture has none. About 15s locally; no CI duration measured yet; assigned to shard 2, the lowest measured total."
+ },
{
"file": "test-issue-562-battery-badge-e2e.js",
"shard": 2,
diff --git a/test-all.sh b/test-all.sh
index 8db537840..d38f74e52 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -92,6 +92,7 @@ run test-analytics-channels-integration.js
run test-analytics-foreign-traffic-tab.js
run test-scope-audit.js
run test-analytics-nodes-without-scope.js
+run test-issue-560-role-breakdown.js
run test-analytics-table-ids-unique.js
run test-analytics-relay-airtime-dumbbell.js
run test-live-vcr-mode-contrast.js
diff --git a/test-fetch-all-nodes-pagination.js b/test-fetch-all-nodes-pagination.js
index b53689498..0b2db07b9 100644
--- a/test-fetch-all-nodes-pagination.js
+++ b/test-fetch-all-nodes-pagination.js
@@ -109,6 +109,7 @@ test('surfaces ALL nodes past the 500 server cap (1200 > 500)', async () => {
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);
+ assert.strictEqual(out.truncated, false, 'a short final page means the population is complete, not truncated');
});
test('stops on a short page rather than the unreliable server total', async () => {
@@ -120,6 +121,7 @@ test('stops on a short page rather than the unreliable server total', async () =
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');
+ assert.strictEqual(out.truncated, false, 'a trailing empty page means the population is complete, not truncated');
});
test('dedups a public_key repeated across a page boundary', async () => {
@@ -165,6 +167,36 @@ test('safetyCap is a hard node-count ceiling (no pageSize overshoot)', async ()
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');
+ assert.strictEqual(out.truncated, true, 'the real 5000-node population exceeds the 800 safetyCap, so the result must say so');
+});
+
+// #560 follow-up: a caller presenting `nodes` as the complete filtered
+// population (analytics.js's Role Breakdown) must be able to tell when it
+// is not — see the two `truncated` assertions above for the "complete"
+// cases. This covers the two ways `truncated` must come back true.
+test('truncated is true when the page loop hits safetyCap without ever seeing a short page', async () => {
+ const ctx = makeSandbox();
+ loadInCtx(ctx, 'public/app.js');
+ // 10000 nodes, safetyCap=1000, pageSize=500: every fetched page (0, 500)
+ // is a full 500-row page, so the loop never learns the real population is
+ // far larger than what it returned.
+ const m = makeNodesFetch(10000, 500);
+ ctx.fetch = m.fetch;
+ const out = await ctx.fetchAllNodes('', { safetyCap: 1000 });
+ assert.strictEqual(out.nodes.length, 1000);
+ assert.strictEqual(out.truncated, true, 'every page fetched was full, so more rows may exist beyond safetyCap');
+});
+
+test('truncated is false when the real population exactly fits under safetyCap', async () => {
+ const ctx = makeSandbox();
+ loadInCtx(ctx, 'public/app.js');
+ // 1000 nodes, safetyCap well above it: the loop sees a short (0-length)
+ // page before ever approaching the cap, so the population is complete.
+ const m = makeNodesFetch(1000, 500);
+ ctx.fetch = m.fetch;
+ const out = await ctx.fetchAllNodes('', { safetyCap: 10000 });
+ assert.strictEqual(out.nodes.length, 1000);
+ assert.strictEqual(out.truncated, false, 'a short page was seen well under safetyCap, so the population is complete');
});
test('rows missing public_key are NOT collapsed into one', async () => {
diff --git a/test-issue-560-role-breakdown-e2e.js b/test-issue-560-role-breakdown-e2e.js
new file mode 100644
index 000000000..0d764fec4
--- /dev/null
+++ b/test-issue-560-role-breakdown-e2e.js
@@ -0,0 +1,335 @@
+/**
+ * E2E (Relates to #560): Analytics -> Nodes' Role Breakdown must describe
+ * the SAME population as that view's own Network Status once a region
+ * filter is active, not the server's global /api/nodes `counts` metadata
+ * (cmd/server/db.go GetAllRoleCounts, an unconditional COUNT(*) over the
+ * whole `nodes` table regardless of the region/area filter on the page).
+ *
+ * Against a real browser and the Go server on the CI fixture (regions MRY,
+ * OAK, SFO, SJC; 200 nodes total: 168 repeaters, 23 rooms, 9 companions, 0
+ * sensors). MRY holds exactly 13 nodes, all repeaters -- a region small
+ * enough, and lopsided enough, that the pre-fix bug is unmistakable: every
+ * badge kept showing the unfiltered global counts (168/23/9/0) no matter
+ * which region was picked, while the Network Status "Total Nodes" card
+ * (already computed from the filtered rows before this fix) correctly
+ * dropped to 13 -- two numbers for what is supposed to be one population.
+ *
+ * Usage: BASE_URL=http://localhost:13820 node test-issue-560-role-breakdown-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const net = require('net');
+
+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'); }
+
+async function openPage(browser) {
+ const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+ const page = await ctx.newPage();
+ page.__errors = [];
+ page.on('pageerror', (e) => page.__errors.push(e.message));
+ page.__reqs = [];
+ page.__open = new Set();
+ page.on('request', (r) => { if (r.url().includes('/api/')) { page.__reqs.push(r.url()); page.__open.add(r); } });
+ page.on('requestfinished', (r) => page.__open.delete(r));
+ page.on('requestfailed', (r) => page.__open.delete(r));
+ return page;
+}
+
+// Until no /api/ request is open and none started for QUIET_MS (bounded).
+// A fixed settle window alone is unreliable on a loaded machine.
+const QUIET_MS = 800;
+async function apiIdle(page) {
+ const deadline = Date.now() + 20000;
+ let n = page.__reqs.length, quietFrom = Date.now();
+ while (Date.now() < deadline) {
+ await page.waitForTimeout(100);
+ if (page.__reqs.length !== n || page.__open.size > 0) { n = page.__reqs.length; quietFrom = Date.now(); continue; }
+ if (Date.now() - quietFrom >= QUIET_MS) return;
+ }
+}
+
+function roleBreakdown(page) {
+ return page.evaluate(() => {
+ const h3 = Array.from(document.querySelectorAll('#analyticsContent h3')).find((h) => h.textContent.includes('Role Breakdown'));
+ if (!h3) return null;
+ const row = h3.nextElementSibling;
+ const counts = {};
+ Array.from(row.querySelectorAll('.badge')).forEach((b) => {
+ const m = /^(\w+):\s*(\d+)$/.exec(b.textContent.trim());
+ if (m) counts[m[1]] = Number(m[2]);
+ });
+ return counts;
+ });
+}
+
+function totalNodesStat(page) {
+ return page.evaluate(() => {
+ const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent));
+ if (!card) return null;
+ const m = /(\d+)/.exec(card.textContent);
+ return m ? Number(m[1]) : null;
+ });
+}
+
+async function pickRegion(page, code) {
+ await page.waitForSelector('#analyticsRegionFilter [data-region="' + code + '"]', { state: 'attached', timeout: 15000 });
+ await page.evaluate((c) => document.querySelector('#analyticsRegionFilter [data-region="' + c + '"]').click(), code);
+}
+
+function sumOf(counts) {
+ return Object.keys(counts).reduce((a, k) => a + counts[k], 0);
+}
+
+// Order-independent equality for the four known-role count objects --
+// /api/nodes' `counts` and the DOM-parsed Role Breakdown enumerate their
+// keys in different orders, so a JSON.stringify comparison is not safe.
+function sameCounts(a, b) {
+ const keys = ['repeaters', 'rooms', 'companions', 'sensors'];
+ return keys.every((k) => (a[k] || 0) === (b[k] || 0));
+}
+
+// ===== area-only / combined region+area: CS-Minimax PR#580 round-2 review,
+// finding 1. The shared BASE_URL server (and the committed e2e-fixture.db)
+// has no `areas` configured at all, so an area filter cannot be exercised
+// against it. Spin up a SEPARATE, disposable server instance -- a copy of
+// the same migrated fixture, with one test-only area added via -config-dir
+// -- mirroring the private-server pattern already used by
+// test-issue-315-estimated-positions-e2e.js. Requires CORESCOPE_SERVER_BIN
+// and CORESCOPE_MIGRATE_BIN (already set for that suite in e2e-shards.json);
+// skips gracefully when they are not set, e.g. for a quick local run of just
+// this file against BASE_URL.
+async function freePort() {
+ const socket = net.createServer();
+ await new Promise((resolve, reject) => { socket.once('error', reject); socket.listen(0, '127.0.0.1', resolve); });
+ const port = socket.address().port;
+ await new Promise((resolve) => socket.close(resolve));
+ return port;
+}
+
+async function stopChild(child) {
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
+ const ended = new Promise((resolve) => child.once('exit', resolve));
+ child.kill('SIGTERM');
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
+ await ended;
+ clearTimeout(timer);
+}
+
+async function waitServerHealthy(base, child, getLog) {
+ const deadline = Date.now() + 15000;
+ while (Date.now() < deadline) {
+ assert(child.exitCode === null && child.signalCode === null, 'private server exited early: ' + getLog());
+ try {
+ const r = await fetch(base + '/api/healthz');
+ if (r.ok) return;
+ } catch (_) { /* not listening yet */ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error('private server did not become healthy in time: ' + getLog());
+}
+
+// A bounding box covering 6 of the fixture's 13 MRY-region nodes (all
+// repeaters) plus 43 further nodes outside MRY (38 repeaters, 4 rooms, 1
+// companion) -- chosen by inspecting the committed fixture's real node
+// coordinates, so area-only (49), region-only (13) and the MRY∩SFBAY
+// combination (6) are three genuinely different, non-overlapping-with-
+// unfiltered populations.
+const SFBAY_AREA_CONFIG = { label: 'SF Bay (test)', latMin: 37.7, latMax: 38.0, lonMin: -122.6, lonMax: -122.0 };
+
+async function withPrivateAreaServer(fn) {
+ const serverBin = process.env.CORESCOPE_SERVER_BIN;
+ const migrateBin = process.env.CORESCOPE_MIGRATE_BIN;
+ if (!serverBin || !migrateBin) {
+ console.log(' … SKIP area-only / combined region+area checks (set CORESCOPE_SERVER_BIN and CORESCOPE_MIGRATE_BIN to run them)');
+ return;
+ }
+ const { execFileSync } = require('child_process');
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corescope-560-e2e-'));
+ let child = null;
+ try {
+ const database = path.join(scratch, 'fixture.db');
+ fs.copyFileSync(path.join(__dirname, 'test-fixtures/e2e-fixture.db'), database);
+ execFileSync(migrateBin, ['-db', database], { stdio: 'pipe' });
+ fs.writeFileSync(path.join(scratch, 'config.json'), JSON.stringify({ areas: { SFBAY: SFBAY_AREA_CONFIG } }));
+ const port = await freePort();
+ const base = 'http://127.0.0.1:' + port;
+ let log = '';
+ child = spawn(serverBin, ['-config-dir', scratch, '-db', database, '-public', path.join(__dirname, 'public'), '-port', String(port)],
+ { cwd: __dirname, stdio: ['ignore', 'pipe', 'pipe'] });
+ child.stdout.on('data', (c) => { log = (log + c).slice(-20000); });
+ child.stderr.on('data', (c) => { log = (log + c).slice(-20000); });
+ await waitServerHealthy(base, child, () => log);
+ await fn(base);
+ } finally {
+ await stopChild(child);
+ fs.rmSync(scratch, { recursive: true, force: true });
+ }
+}
+
+async function pickArea(page, key) {
+ await page.waitForSelector('#analyticsAreaFilter .region-dropdown-trigger', { state: 'attached', timeout: 15000 });
+ await page.click('#analyticsAreaFilter .region-dropdown-trigger');
+ await page.waitForSelector('#analyticsAreaFilter [data-area="' + key + '"]', { state: 'attached', timeout: 15000 });
+ await page.click('#analyticsAreaFilter [data-area="' + key + '"]');
+}
+
+(async () => {
+ const browser = await chromium.launch();
+ try {
+ const page = await openPage(browser);
+
+ let unfilteredTotal, unfilteredBreakdown;
+ await step('loads the Nodes analytics tab unfiltered', async () => {
+ await page.goto(BASE + '/#/analytics?tab=nodes', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#analyticsContent h3:has-text("Role Breakdown")', { timeout: 15000 });
+ await apiIdle(page);
+ unfilteredTotal = await totalNodesStat(page);
+ unfilteredBreakdown = await roleBreakdown(page);
+ assert(unfilteredTotal > 0, 'Total Nodes must render a positive count, got ' + unfilteredTotal);
+ assert(sumOf(unfilteredBreakdown) === unfilteredTotal,
+ 'unfiltered role-breakdown sum (' + sumOf(unfilteredBreakdown) + ') must equal Total Nodes (' + unfilteredTotal + ')');
+ });
+
+ // CS-Minimax PR#580 round-2 review, finding 4: the suite checked the
+ // breakdown's own internal sum against Total Nodes, but never against
+ // the server's /api/nodes `counts` directly. Unfiltered, these two MUST
+ // agree -- that is the baseline #560 preserves.
+ await step('unfiltered Role Breakdown equals /api/nodes\' own global counts', async () => {
+ const serverCounts = await page.evaluate(() => fetch('/api/nodes?limit=1').then((r) => r.json()).then((d) => d.counts));
+ assert(sameCounts(unfilteredBreakdown, serverCounts),
+ 'unfiltered breakdown ' + JSON.stringify(unfilteredBreakdown) + ' must equal /api/nodes counts ' + JSON.stringify(serverCounts));
+ });
+
+ // CS-Minimax PR#580 round-2 review, finding 3: this PR touches no Go,
+ // so the global /api/nodes `counts` contract (an unconditional COUNT(*)
+ // per role, unaffected by region/area filters -- GetAllRoleCounts in
+ // cmd/server/db.go) must still hold. Make that explicit rather than
+ // leaving it implicit in "no Go changed".
+ await step('/api/nodes global counts contract is unaffected by a region filter', async () => {
+ const [plain, mry] = await Promise.all([
+ page.evaluate(() => fetch('/api/nodes?limit=1').then((r) => r.json())),
+ page.evaluate(() => fetch('/api/nodes?limit=1®ion=MRY').then((r) => r.json())),
+ ]);
+ for (const key of ['repeaters', 'rooms', 'companions', 'sensors']) {
+ assert(typeof plain.counts[key] === 'number', 'counts.' + key + ' must be a number, got: ' + JSON.stringify(plain.counts));
+ }
+ assert(sameCounts(mry.counts, plain.counts),
+ 'global counts must stay the server-wide total under a region filter (this PR only changed the CLIENT-side breakdown): ' +
+ JSON.stringify(mry.counts) + ' vs ' + JSON.stringify(plain.counts));
+ });
+
+ await step('an MRY region filter: the role breakdown sum equals the Network Status total', async () => {
+ await pickRegion(page, 'MRY');
+ await apiIdle(page);
+ await page.waitForFunction(() => {
+ const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent));
+ return !!card && !/^\s*$/.test(card.textContent);
+ }, { timeout: 15000 });
+
+ const total = await totalNodesStat(page);
+ const breakdown = await roleBreakdown(page);
+
+ assert(total < unfilteredTotal, 'MRY must narrow Total Nodes below the unfiltered ' + unfilteredTotal + ', got ' + total);
+ const sum = sumOf(breakdown);
+ assert(sum === total, 'MRY-filtered role-breakdown sum (' + sum + ') must equal the Network Status total (' + total + ')');
+
+ // The regression this closes: before the fix every badge showed the
+ // server's global, filter-blind counts no matter which region was
+ // active, so the filtered breakdown was identical to the unfiltered
+ // one. MRY's own repeater count must differ from the unfiltered one.
+ assert(breakdown.repeaters !== unfilteredBreakdown.repeaters,
+ 'MRY repeaters (' + breakdown.repeaters + ') must differ from the unfiltered global count (' + unfilteredBreakdown.repeaters + ')');
+ });
+
+ await step('clearing the region filter restores the unfiltered numbers (no leftover stale state)', async () => {
+ await pickRegion(page, '__all__');
+ await apiIdle(page);
+ await page.waitForFunction((want) => {
+ const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent));
+ const m = card && /(\d+)/.exec(card.textContent);
+ return !!m && Number(m[1]) === want;
+ }, unfilteredTotal, { timeout: 15000 });
+
+ const total = await totalNodesStat(page);
+ const breakdown = await roleBreakdown(page);
+ assert(total === unfilteredTotal, 'clearing the filter should restore Total Nodes to ' + unfilteredTotal + ', got ' + total);
+ assert(sumOf(breakdown) === total, 'post-clear role-breakdown sum must again equal Total Nodes');
+ assert(breakdown.repeaters === unfilteredBreakdown.repeaters, 'post-clear repeaters count must match the original unfiltered count');
+ });
+
+ await step('no page errors were raised', async () => {
+ assert(page.__errors.length === 0, 'page errors: ' + page.__errors.join('; '));
+ });
+
+ // CS-Minimax PR#580 round-2 review, finding 1: area-only and combined
+ // region+area coverage, against a private server with one test area.
+ await withPrivateAreaServer(async (base) => {
+ const areaPage = await openPage(browser);
+ let baseTotal;
+ await step('loads the Nodes analytics tab unfiltered on the private area server', async () => {
+ await areaPage.goto(base + '/#/analytics?tab=nodes', { waitUntil: 'domcontentloaded' });
+ await areaPage.waitForSelector('#analyticsContent h3:has-text("Role Breakdown")', { timeout: 15000 });
+ await apiIdle(areaPage);
+ baseTotal = await totalNodesStat(areaPage);
+ assert(baseTotal > 0, 'Total Nodes must render a positive count, got ' + baseTotal);
+ });
+
+ await step('an area-only filter: the role breakdown sum equals the Network Status total, and narrows the unfiltered total', async () => {
+ await pickArea(areaPage, 'SFBAY');
+ await apiIdle(areaPage);
+ await areaPage.waitForFunction((want) => {
+ const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent));
+ const m = card && /(\d+)/.exec(card.textContent);
+ return !!m && Number(m[1]) !== want;
+ }, baseTotal, { timeout: 15000 });
+
+ const total = await totalNodesStat(areaPage);
+ const breakdown = await roleBreakdown(areaPage);
+ assert(total !== baseTotal, 'the SFBAY area must narrow Total Nodes from the unfiltered ' + baseTotal + ', got ' + total);
+ assert(sumOf(breakdown) === total,
+ 'area-only role-breakdown sum (' + sumOf(breakdown) + ') must equal the Network Status total (' + total + ')');
+ });
+
+ await step('a combined region+area filter narrows further, and the breakdown sum still equals the total', async () => {
+ await pickRegion(areaPage, 'MRY');
+ await apiIdle(areaPage);
+ await areaPage.waitForFunction(() => {
+ const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent));
+ return !!card && !/^\s*$/.test(card.textContent);
+ }, { timeout: 15000 });
+
+ const total = await totalNodesStat(areaPage);
+ const breakdown = await roleBreakdown(areaPage);
+ // MRY ∩ SFBAY is exactly 6 of the fixture's known nodes, all
+ // repeaters (verified directly against the Go server's own
+ // /api/nodes?area=SFBAY®ion=MRY response while building this
+ // fixture area -- see SFBAY_AREA_CONFIG above).
+ assert(total === 6, 'MRY ∩ SFBAY must be exactly 6 nodes in this fixture, got ' + total);
+ assert(sumOf(breakdown) === total,
+ 'combined region+area role-breakdown sum (' + sumOf(breakdown) + ') must equal the Network Status total (' + total + ')');
+ assert(breakdown.repeaters === 6, 'all 6 MRY ∩ SFBAY nodes are repeaters in this fixture, got ' + breakdown.repeaters);
+ });
+
+ await step('no page errors were raised on the private area server', async () => {
+ assert(areaPage.__errors.length === 0, 'page errors: ' + areaPage.__errors.join('; '));
+ });
+ await areaPage.close();
+ });
+ } finally {
+ await browser.close();
+ }
+
+ console.log('\n' + passed + ' passed, ' + failed + ' failed');
+ process.exit(failed === 0 ? 0 : 1);
+})();
diff --git a/test-issue-560-role-breakdown.js b/test-issue-560-role-breakdown.js
new file mode 100644
index 000000000..053e7f119
--- /dev/null
+++ b/test-issue-560-role-breakdown.js
@@ -0,0 +1,319 @@
+/**
+ * Unit + integration tests for #560 (Relates to #560): Analytics -> Nodes'
+ * Role Breakdown must be computed from the already-loaded, FILTERED node
+ * rows (region + area), not from /api/nodes' global `counts` metadata
+ * (cmd/server/db.go GetAllRoleCounts, unconditional over the whole `nodes`
+ * table). That global count still backs other consumers unchanged.
+ *
+ * Section A: the pure counting function nodeRoleBreakdownCounts
+ * (window._analyticsNodeRoleBreakdownCounts) -- known roles, an unknown
+ * role, and an empty row set.
+ * Section B: renderNodesTab (window._analyticsRenderNodesTab) wired end to
+ * end against stub fetchAllNodes/api -- synthetic global counts that
+ * disagree with the filtered rows, exactly one network round trip per
+ * render, and a late (superseded) response that must not clobber a
+ * newer render's output.
+ */
+'use strict';
+
+const vm = require('vm');
+const fs = require('fs');
+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}`);
+ }
+}
+async function testAsync(name, fn) {
+ try {
+ await fn();
+ passed++;
+ console.log(` ✅ ${name}`);
+ } catch (e) {
+ failed++;
+ console.log(` ❌ ${name}: ${e.message}`);
+ }
+}
+
+// Same sandbox shape as test-analytics-nodes-without-scope.js.
+function makeSandbox() {
+ const ctx = {
+ window: { addEventListener: () => {}, dispatchEvent: () => {} },
+ 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: () => {},
+ fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
+ 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, Promise, URLSearchParams,
+ addEventListener: () => {},
+ dispatchEvent: () => {},
+ requestAnimationFrame: (cb) => setTimeout(cb, 0),
+ };
+ vm.createContext(ctx);
+ return ctx;
+}
+
+function loadInCtx(ctx, file) {
+ if (!ctx.__payloadLabelsLoaded && file !== 'public/payload-labels.js') {
+ ctx.__payloadLabelsLoaded = true;
+ vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx);
+ }
+ vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
+ for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
+}
+
+// --- fetchAllNodes/api stubs recorded per test so each case can assert on
+// exactly how many times the network was touched and with what the render
+// saw as its synthetic "server" response.
+function makeAnalyticsSandbox() {
+ const ctx = makeSandbox();
+ ctx.getComputedStyle = () => ({ getPropertyValue: () => '' });
+ ctx.registerPage = () => {};
+ ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
+ ctx.AreaFilter = { init: () => {}, onChange: () => {}, areaQueryString: () => '' };
+ ctx.onWS = () => {};
+ ctx.offWS = () => {};
+ ctx.connectWS = () => {};
+ ctx.invalidateApiCache = () => {};
+ ctx.makeColumnsResizable = () => {};
+ ctx.initTabBar = () => {};
+ ctx.IATA_COORDS_GEO = {};
+ loadInCtx(ctx, 'public/roles.js');
+ loadInCtx(ctx, 'public/app.js');
+ loadInCtx(ctx, 'public/heard-by.js');
+ try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) {
+ for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
+ }
+ return ctx;
+}
+
+const ctx = makeAnalyticsSandbox();
+const nodeRoleBreakdownCounts = ctx.window._analyticsNodeRoleBreakdownCounts;
+const renderNodesTab = ctx.window._analyticsRenderNodesTab;
+
+console.log('\n=== analytics.js: nodeRoleBreakdownCounts (unit) ===');
+
+test('is exported for testing', () => {
+ assert.strictEqual(typeof nodeRoleBreakdownCounts, 'function');
+});
+
+test('counts each known role, pluralized -- same shape as GetAllRoleCounts', () => {
+ const nodes = [
+ { public_key: 'p1', role: 'repeater' },
+ { public_key: 'p2', role: 'repeater' },
+ { public_key: 'p3', role: 'room' },
+ { public_key: 'p4', role: 'companion' },
+ { public_key: 'p5', role: 'sensor' },
+ ];
+ assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts(nodes)), JSON.stringify({ repeaters: 2, rooms: 1, companions: 1, sensors: 1 }));
+});
+
+test('an unknown/unrecognized role is not tallied into any bucket and does not throw', () => {
+ const nodes = [
+ { public_key: 'p1', role: 'repeater' },
+ { public_key: 'p2', role: 'observer' }, // a real role, but not one GetAllRoleCounts tracks today
+ { public_key: 'p3', role: 'gateway' }, // never a known role at all
+ { public_key: 'p4', role: null },
+ { public_key: 'p5' }, // role field missing entirely
+ ];
+ const result = nodeRoleBreakdownCounts(nodes);
+ assert.strictEqual(JSON.stringify(result), JSON.stringify({ repeaters: 1, rooms: 0, companions: 0, sensors: 0 }));
+ const sum = Object.keys(result).reduce((a, k) => a + result[k], 0);
+ assert.ok(sum < nodes.length, 'unknown-role rows must not inflate any known bucket');
+});
+
+test('an empty row set renders zero for every known role, not an absent/undefined count', () => {
+ assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts([])), JSON.stringify({ repeaters: 0, rooms: 0, companions: 0, sensors: 0 }));
+});
+
+test('a non-array input is treated as empty rather than throwing', () => {
+ const zero = JSON.stringify({ repeaters: 0, rooms: 0, companions: 0, sensors: 0 });
+ assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts(undefined)), zero);
+ assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts(null)), zero);
+});
+
+// =====================================================================
+// Section B -- renderNodesTab wired end to end.
+// =====================================================================
+
+function makeEl() {
+ return { _h: '', set innerHTML(v) { this._h = v; }, get innerHTML() { return this._h; } };
+}
+
+function extractBadge(html, key) {
+ const m = new RegExp(key + ':\\s*(\\d+)').exec(html);
+ return m ? Number(m[1]) : null;
+}
+
+console.log('\n=== analytics.js: renderNodesTab (integration) ===');
+
+(async () => {
+ await testAsync('renders the Role Breakdown from the filtered rows, not from a disagreeing global `counts`', async () => {
+ let fetchCalls = 0, apiCalls = 0;
+ const filteredNodes = [
+ { public_key: 'p1', role: 'repeater', last_seen: new Date().toISOString() },
+ { public_key: 'p2', role: 'repeater', last_seen: new Date().toISOString() },
+ { public_key: 'p3', role: 'room', last_seen: new Date().toISOString() },
+ ];
+ ctx.fetchAllNodes = async () => {
+ fetchCalls++;
+ // Deliberately disagreeing global counts (as if the region filter
+ // returned 3 rows while the unfiltered mesh has far more of each role).
+ return { nodes: filteredNodes, counts: { repeaters: 999, rooms: 999, companions: 999, sensors: 999 }, total: filteredNodes.length };
+ };
+ ctx.api = async () => { apiCalls++; return []; };
+
+ const el = makeEl();
+ await renderNodesTab(el);
+
+ assert.strictEqual(fetchCalls, 1, 'exactly one fetchAllNodes call -- no extra request added by the fix');
+ assert.strictEqual(apiCalls, 1, 'exactly one bulk-health call -- no extra request added by the fix');
+ assert.strictEqual(extractBadge(el._h, 'repeaters'), 2, 'repeaters badge must come from the 2 filtered rows, not the global 999');
+ assert.strictEqual(extractBadge(el._h, 'rooms'), 1, 'rooms badge must come from the 1 filtered row, not the global 999');
+ assert.strictEqual(extractBadge(el._h, 'companions'), 0);
+ assert.strictEqual(extractBadge(el._h, 'sensors'), 0);
+ assert.ok(!/999/.test(el._h), 'the disagreeing global count must never reach the page');
+
+ // Network Status total and the role-breakdown sum describe the same
+ // (filtered) population, since every row above has a known role.
+ const totalMatch = /font-weight:700">(\d+)<\/div>\s*
]*>Total Nodes/.exec(el._h);
+ assert.ok(totalMatch, 'Total Nodes stat must render');
+ const total = Number(totalMatch[1]);
+ const sum = ['repeaters', 'rooms', 'companions', 'sensors'].reduce((a, k) => a + extractBadge(el._h, k), 0);
+ assert.strictEqual(sum, total, 'role-breakdown sum must equal the Network Status total for an all-known-role population');
+ });
+
+ await testAsync('an empty filtered result renders zero for every role, not a fallback to any global count', async () => {
+ ctx.fetchAllNodes = async () => ({ nodes: [], counts: { repeaters: 42, rooms: 7, companions: 3, sensors: 1 }, total: 0 });
+ ctx.api = async () => [];
+
+ const el = makeEl();
+ await renderNodesTab(el);
+
+ assert.strictEqual(extractBadge(el._h, 'repeaters'), 0);
+ assert.strictEqual(extractBadge(el._h, 'rooms'), 0);
+ assert.strictEqual(extractBadge(el._h, 'companions'), 0);
+ assert.strictEqual(extractBadge(el._h, 'sensors'), 0);
+ assert.ok(!/42|>7<|>3<|>1 {
+ // First call's fetch is held open; second call (a simulated filter
+ // change while the first is still pending) resolves immediately. The
+ // first call's response must be discarded once it finally arrives.
+ let resolveFirst;
+ let call = 0;
+ ctx.fetchAllNodes = async () => {
+ call++;
+ if (call === 1) {
+ return new Promise((resolve) => { resolveFirst = resolve; });
+ }
+ return { nodes: [{ public_key: 'new1', role: 'companion', last_seen: new Date().toISOString() }], counts: {}, total: 1 };
+ };
+ ctx.api = async () => [];
+
+ const el = makeEl();
+ const firstRender = renderNodesTab(el); // not awaited -- still pending
+ await renderNodesTab(el); // second, newer render resolves first
+
+ assert.strictEqual(extractBadge(el._h, 'companions'), 1, 'the newer render\'s single companion must be showing');
+ assert.strictEqual(extractBadge(el._h, 'repeaters'), 0);
+
+ // Now let the stale first request resolve with different data.
+ resolveFirst({ nodes: [{ public_key: 'old1', role: 'repeater' }, { public_key: 'old2', role: 'repeater' }], counts: {}, total: 2 });
+ await firstRender;
+
+ assert.strictEqual(extractBadge(el._h, 'companions'), 1, 'the stale response must not have overwritten the newer render');
+ assert.strictEqual(extractBadge(el._h, 'repeaters'), 0, 'the stale response\'s repeater rows must never have reached the page');
+ });
+
+ // =====================================================================
+ // Section C -- CS-Minimax PR#580 round-2 review, finding 2: fetchAllNodes
+ // silently slices to its safetyCap (public/app.js). A truncated node list
+ // must not be presented to the user as a complete population.
+ // =====================================================================
+ console.log('\n=== analytics.js: renderNodesTab surfaces a truncated node list ===');
+
+ await testAsync('shows a truncation notice when fetchAllNodes reports truncated: true', async () => {
+ ctx.fetchAllNodes = async () => ({
+ nodes: [{ public_key: 'p1', role: 'repeater', last_seen: new Date().toISOString() }],
+ counts: {}, total: 1, truncated: true,
+ });
+ ctx.api = async () => [];
+ const el = makeEl();
+ await renderNodesTab(el);
+ assert.ok(/truncat/i.test(el._h), 'a truncated node list must render a visible notice, got: ' + el._h.slice(0, 200));
+ });
+
+ await testAsync('shows no truncation notice when fetchAllNodes reports truncated: false', async () => {
+ ctx.fetchAllNodes = async () => ({
+ nodes: [{ public_key: 'p1', role: 'repeater', last_seen: new Date().toISOString() }],
+ counts: {}, total: 1, truncated: false,
+ });
+ ctx.api = async () => [];
+ const el = makeEl();
+ await renderNodesTab(el);
+ assert.ok(!/truncat/i.test(el._h), 'a complete node list must not render a truncation notice, got: ' + el._h.slice(0, 200));
+ });
+
+ // =====================================================================
+ // Section D -- CS-Minimax PR#580 round-2 review, finding 1: an area-only
+ // filter and a combined region+area filter must both reach fetchAllNodes
+ // -- the unit sandbox previously stubbed AreaFilter.areaQueryString() to
+ // '' and never varied it, so a regression dropping AreaFilter's
+ // contribution to the request (mutant M7) passed the whole suite.
+ // =====================================================================
+ console.log('\n=== analytics.js: renderNodesTab passes area/region+area query to fetchAllNodes ===');
+
+ await testAsync('an area-only filter is included in the fetchAllNodes query', async () => {
+ let recordedQuery = null;
+ ctx.RegionFilter.regionQueryString = () => '';
+ ctx.AreaFilter.areaQueryString = () => '&area=SFBAY';
+ ctx.fetchAllNodes = async (q) => { recordedQuery = q; return { nodes: [], counts: {}, total: 0 }; };
+ ctx.api = async () => [];
+ const el = makeEl();
+ await renderNodesTab(el);
+ assert.ok(recordedQuery && recordedQuery.indexOf('&area=SFBAY') !== -1,
+ 'an area-only filter must reach fetchAllNodes\' query, got: ' + recordedQuery);
+ ctx.AreaFilter.areaQueryString = () => '';
+ });
+
+ await testAsync('a combined region+area filter includes BOTH pieces in the fetchAllNodes query', async () => {
+ let recordedQuery = null;
+ ctx.RegionFilter.regionQueryString = () => '®ion=MRY';
+ ctx.AreaFilter.areaQueryString = () => '&area=SFBAY';
+ ctx.fetchAllNodes = async (q) => { recordedQuery = q; return { nodes: [], counts: {}, total: 0 }; };
+ ctx.api = async () => [];
+ const el = makeEl();
+ await renderNodesTab(el);
+ assert.ok(recordedQuery && recordedQuery.indexOf('®ion=MRY') !== -1 && recordedQuery.indexOf('&area=SFBAY') !== -1,
+ 'a combined region+area filter must pass BOTH pieces to fetchAllNodes\' query, got: ' + recordedQuery);
+ ctx.RegionFilter.regionQueryString = () => '';
+ ctx.AreaFilter.areaQueryString = () => '';
+ });
+
+ console.log('\n════════════════════════════════════════');
+ console.log(` Role Breakdown (#560): ${passed} passed, ${failed} failed`);
+ console.log('════════════════════════════════════════');
+ process.exit(failed === 0 ? 0 : 1);
+})();