From 73bffc8465760a1f55aeaa016b9d121dbd8d5224 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 14 Oct 2026 10:04:52 -0400 Subject: [PATCH] fix(analytics): count node roles within the selected filters --- public/analytics.js | 12 +++--- tests/e2e/test-analytics-loading-e2e.js | 14 ++++++- tests/unit/test-analytics-loading.js | 56 ++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index ec17474ee..306b721bc 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2524,11 +2524,14 @@ const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0)); const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard)); - // Compute network status client-side from loaded nodes using shared getHealthThresholds() + // Status and roles must describe the same filtered nodes; API counts are global. + const roleColors = ROLE_COLORS; + const roleCounts = Object.fromEntries(Object.keys(roleColors).map(role => [role, 0])); const now = Date.now(); let active = 0, degraded = 0, silent = 0; nodes.forEach(function(n) { - const role = n.role || 'unknown'; + const role = Object.prototype.hasOwnProperty.call(roleCounts, n.role) ? n.role : 'unknown'; + roleCounts[role]++; const th = getHealthThresholds(role); const lastMs = n.last_heard ? new Date(n.last_heard).getTime() : n.last_seen ? new Date(n.last_seen).getTime() @@ -2539,7 +2542,6 @@ else silent++; }); const totalNodes = nodesResp.total || nodes.length; - const roleCounts = nodesResp.counts || {}; function nodeLink(n) { return `${esc(n.name || n.public_key.slice(0, 12))}`; @@ -2575,8 +2577,8 @@

Role Breakdown

${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => { - const c = ROLE_COLORS[role] || '#6b7280'; - return `${role}: ${count}`; + const c = roleColors[role]; + return `${esc(role)}: ${count}`; }).join('')}
diff --git a/tests/e2e/test-analytics-loading-e2e.js b/tests/e2e/test-analytics-loading-e2e.js index 57689648f..bec67cc84 100644 --- a/tests/e2e/test-analytics-loading-e2e.js +++ b/tests/e2e/test-analytics-loading-e2e.js @@ -69,7 +69,7 @@ const FIXTURES = { if (!gate || !['/api/nodes', '/api/nodes/bulk-health'].includes(url.pathname)) return route.continue(); const publicKey = 'ab'.repeat(32); let status = 200; - let body = { nodes: [{ public_key: publicKey, name: region + ' synthetic node', role: 'repeater', last_seen: new Date().toISOString() }], has_more: false, counts: { repeater: 1 }, total: 1 }; + let body = { nodes: [{ public_key: publicKey, name: region + ' synthetic node', role: 'repeater', last_seen: new Date().toISOString() }], has_more: false, counts: { repeater: 9000, companion: 8000, room: 7000 }, total: 1 }; if (url.pathname.endsWith('/bulk-health')) { gate.arrived.resolve(url.pathname.slice('/api'.length) + url.search); status = await gate.response.promise; @@ -164,7 +164,17 @@ const FIXTURES = { await releaseLazy('E2E-OLD'); assert.match(await page.locator('#analyticsContent').innerText(), /E2E-NEW synthetic node/); assert.doesNotMatch(await page.locator('#analyticsContent').innerText(), /E2E-OLD synthetic node/); - console.log(' ✓ lazy Nodes keeps the newest filter when the old response arrives last'); + const subset = await page.evaluate(() => { + const content = document.getElementById('analyticsContent'); + const heading = Array.from(content.querySelectorAll('h3')).find(el => el.textContent.trim() === 'Role Breakdown'); + return { + roles: Array.from(heading.nextElementSibling.querySelectorAll('.badge'), el => el.textContent.trim()), + total: content.querySelectorAll('.analytics-stat-card')[3].firstElementChild.textContent.trim(), + }; + }); + assert.deepStrictEqual(subset.roles, ['repeater: 1', 'companion: 0', 'room: 0', 'sensor: 0', 'observer: 0', 'unknown: 0']); + assert.strictEqual(subset.total, '1', 'role counts must agree with the selected subset total'); + console.log(' ✓ lazy Nodes keeps the newest filter and its role totals when the old response arrives last'); for (const [region, status] of [['E2E-LATE', 200], ['E2E-ERROR', 500]]) { await page.evaluate(region => RegionFilter.setSelected([region]), region); diff --git a/tests/unit/test-analytics-loading.js b/tests/unit/test-analytics-loading.js index b671b782a..c46d8d7cd 100644 --- a/tests/unit/test-analytics-loading.js +++ b/tests/unit/test-analytics-loading.js @@ -6,6 +6,7 @@ const path = require('path'); const vm = require('vm'); const source = fs.readFileSync(path.join(__dirname, '../../public/analytics.js'), 'utf8'); +const rolesSource = fs.readFileSync(path.join(__dirname, '../../public/roles.js'), 'utf8'); const settle = () => new Promise(resolve => setImmediate(resolve)); const unhandled = []; process.on('unhandledRejection', error => unhandled.push(error.message)); @@ -50,17 +51,22 @@ async function harness(initialTab = '') { } const elements = Object.fromEntries(['analyticsContent', 'analyticsTabs', 'analyticsTimeWindow', 'analyticsRegionFilter', 'analyticsAreaFilter'].map(id => [id, element()])); elements.analyticsTabs.querySelector = () => ({ classList: { add() {} } }); - function filter() { - return { init() {}, onChange(fn) { this.change = fn; }, regionQueryString: () => '', areaQueryString: () => '' }; + function filter(key) { + let query = ''; + return { + init() {}, onChange(fn) { this.change = fn; }, + setSelected(value) { query = value ? '&' + key + '=' + encodeURIComponent(value) : ''; this.change(); }, + regionQueryString: () => query, areaQueryString: () => query, + }; } const sandbox = { window: null, console, URLSearchParams, - document: { getElementById: id => elements[id] || null, querySelectorAll: () => [], documentElement: {} }, + document: { getElementById: id => elements[id] || null, querySelectorAll: () => [], documentElement: {}, readyState: 'loading', addEventListener() {} }, location: { hash: '#/analytics' + (initialTab ? '?tab=' + initialTab : '') }, history: { replaceState() {} }, localStorage: { getItem: () => null, setItem() {} }, sessionStorage: { getItem: () => null, setItem() {} }, - RegionFilter: filter(), AreaFilter: filter(), + RegionFilter: filter('region'), AreaFilter: filter('area'), CLIENT_TTL: { analyticsRF: 1000 }, initTabBar() {}, registerPage(name, handlers) { assert.strictEqual(name, 'analytics'); page = handlers; }, @@ -77,8 +83,10 @@ async function harness(initialTab = '') { }, }; sandbox.fetchAllNodes = query => sandbox.api('/nodes?' + query); - sandbox.fetch = url => sandbox.api(url).then(data => ({ ok: true, json: async () => data })); + sandbox.fetch = url => (url === '/api/config/client' ? Promise.resolve({}) : sandbox.api(url)).then(data => ({ ok: true, json: async () => data })); sandbox.window = sandbox; + vm.runInNewContext(rolesSource, sandbox, { filename: 'roles.js' }); + await sandbox.MeshConfigReady; vm.runInNewContext(source, sandbox, { filename: 'analytics.js' }); async function init() { elements.analyticsContent = element(); @@ -102,6 +110,8 @@ async function harness(initialTab = '') { elements.analyticsTimeWindow.listeners.change(); }, refresh: () => sandbox.RegionFilter.change(), + region: value => sandbox.RegionFilter.setSelected(value), + area: value => sandbox.AreaFilter.setSelected(value), async resolve(batch, total) { requests.slice(batch * 6, batch * 6 + 6).forEach(r => r.resolve(fixture(r.url, total))); await settle(); @@ -296,6 +306,42 @@ async function test(name, fn) { } }); + await test('Nodes role counts follow region and area changes without extra requests', async () => { + const h = await harness(); + const cases = [ + { select: () => h.tab('nodes'), query: {}, roles: ['repeater', 'repeater', 'companion'], counts: { repeater: 2, companion: 1 }, requests: 2 }, + { select: () => h.region('E2E-REGION'), query: { region: 'E2E-REGION' }, roles: ['room', 'sensor', 'observer'], counts: { room: 1, sensor: 1, observer: 1 }, requests: 8 }, + { select: () => h.area('E2E-AREA'), query: { region: 'E2E-REGION', area: 'E2E-AREA' }, roles: ['sensor'], counts: { sensor: 1 }, requests: 8 }, + { select: () => h.area('E2E-EMPTY'), query: { region: 'E2E-REGION', area: 'E2E-EMPTY' }, roles: [], counts: {}, requests: 8 }, + { select: () => h.area(''), query: { region: 'E2E-REGION' }, roles: ['unknown', null, undefined, '', 'new-role', '__proto__', 'constructor', 'toString', ''], counts: { unknown: 9 }, requests: 8 }, + { select: () => h.region(''), query: {}, roles: ['repeater', 'companion'], counts: { repeater: 1, companion: 1 }, requests: 8 }, + ]; + for (const entry of cases) { + const start = h.requests.length; + entry.select(); + const pending = h.requests.slice(start); + const nodes = entry.roles.map((role, index) => ({ public_key: String(index), role })); + const nodeRequests = pending.filter(r => r.url.startsWith('/nodes')); + assert.strictEqual(nodeRequests.length, 2, 'only the existing node-list and bulk-health requests'); + nodeRequests.forEach(r => { + const params = new URLSearchParams(r.url.split('?')[1]); + for (const key of ['region', 'area']) assert.strictEqual(params.get(key), entry.query[key] || null); + }); + pending.forEach(r => r.resolve(r.url.startsWith('/nodes?') + ? { nodes, total: nodes.length, counts: { repeater: 9000, companion: 8000, room: 7000 } } + : fixture(r.url, 1))); + await settle(); + assert.doesNotMatch(h.html(), /Failed to load|')[1].split('')[0]; + const counts = Object.fromEntries([...breakdown.matchAll(/>([^<>]+): (\d+)<\/span>/g)].map(match => [match[1], Number(match[2])])); + assert.deepStrictEqual(counts, { repeater: 0, companion: 0, room: 0, sensor: 0, observer: 0, unknown: 0, ...entry.counts }); + assert.strictEqual(Object.values(counts).reduce((sum, count) => sum + count, 0), nodes.length); + const cards = [...h.html().matchAll(/font-size:28px[^>]*>(\d+)<\/div>/g)].map(match => Number(match[1])); + assert.strictEqual(cards[3], nodes.length, 'role badges and Total Nodes use the same subset'); + assert.strictEqual(h.requests.length, start + entry.requests, 'counting roles cannot trigger more fetches'); + } + }); + await test('destroyed lazy requests cannot write into detached content or a new visit', async () => { for (const tab of lazyTabs) { const h = await harness();