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();