Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -2539,7 +2542,6 @@
else silent++;
});
const totalNodes = nodesResp.total || nodes.length;
const roleCounts = nodesResp.counts || {};

function nodeLink(n) {
return `<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a>`;
Expand Down Expand Up @@ -2575,8 +2577,8 @@
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Role Breakdown</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px">
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => {
const c = ROLE_COLORS[role] || '#6b7280';
return `<span class="badge" style="${(window.aaBadgeStyle && window.aaBadgeStyle(c)) || ('background:'+c+';color:#fff')};padding:6px 12px;font-size:13px">${role}: ${count}</span>`;
const c = roleColors[role];
return `<span class="badge" style="${(window.aaBadgeStyle && window.aaBadgeStyle(c)) || ('background:'+c+';color:#fff')};padding:6px 12px;font-size:13px">${esc(role)}: ${count}</span>`;
}).join('')}
</div>

Expand Down
14 changes: 12 additions & 2 deletions tests/e2e/test-analytics-loading-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
56 changes: 51 additions & 5 deletions tests/unit/test-analytics-loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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; },
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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', '<img src=x onerror=alert(1)>'], 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|<img/);
const breakdown = h.html().split('Role Breakdown</h3>')[1].split('</div>')[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();
Expand Down
Loading