Files
meshcore-analyzer/tests/unit/test-nav-stats-throttle.js
T
Alex BandClaude Opus 5.5 323b8040d4 perf(frontend): throttle nav stats to one refresh per 15 s, none in hidden tabs (#2162)
## Summary

The nav bar's packet / node / observer counts were refreshed three ways
at once: a 15 s timer, every batch of live WebSocket packets
(`debouncedOnWS`, 250 ms), and the `/stats` client cache being cleared
every 5 s while packets arrive. None of them checked whether the tab was
visible. So every open tab, including hidden ones, asked `/api/stats`
every few seconds for as long as it stayed open.

On one production instance's nginx log for a single day, `/api/stats`
was **135,654 of 187,411** CoreScope API requests (72%), about 20,000 a
day for each browser that kept a tab open. Each response is cheap thanks
to the 10 s server-side cache (#1910), so this is about request volume,
bandwidth and log noise, not server CPU.

This PR adds `createVisibleThrottle()` to `app.js` and routes all three
triggers through one throttled refresh:

- at most one `/api/stats` request per 15 s from a visible tab (the old
timer's own period);
- none from a hidden tab;
- one immediate refresh when a hidden tab becomes visible again.

No existing issue covers this.

## Performance

Measured with headless Chromium, counting `/api/stats` requests from a
real tab: 60 s visible, then 60 s with the page hidden
(`document.hidden` and a `visibilitychange` event), then back to
visible.

| `/api/stats` requests | production (current release) | staging,
`master` | staging, this PR |
|---|---|---|---|
| visible tab, per minute | 9 | 4 | 4 (at +10, +25, +40, +55 s) |
| hidden tab, per minute | 6 | 5 | **0** |
| on return to visible | n/a | n/a | 1, immediately |

The same check confirmed the nav bar still renders its counts (`… pkts ·
… nodes · … obs`).

## Tests

- Red commit `2c81a3e4`: `tests/unit/test-nav-stats-throttle.js` loads
the real `public/app.js` in a `vm` context. `createVisibleThrottle`
lands as a pass-through stub, so the test fails on its first assertion
(40 refreshes where 1 is expected).
- Green commit `f0ba92ab`: the throttle, and the nav stats timer, WS
hook and a new `visibilitychange` listener calling one throttled
trigger. All 6 cases pass: one refresh per burst, again after the
interval, at most 241 an hour, none while hidden, one on return, and a
source check that `updateNavStats` is wrapped and not called by the
timer directly.
- Added to `test-all.sh` in sorted order; `test-test-inventory.js`
passes.
- Frontend suites: 194 of 195 pass. The one failure,
`test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and
fails the same way on `master` locally.

## Review notes

- `home.js` keeps its own one-shot `/stats` load on the home page; only
the nav bar's polling changed.
- A tab opened in the background loads its counts when it is first
shown.
- The 15 s interval is a constant at the call site. Could be exposed in
the customizer later (AGENTS.md rule 8); not added here.
- No Playwright E2E added: the change is timing behaviour, covered by
the unit test against the real file and by the browser measurement
above. Happy to add an E2E if wanted.

## SonarLint

SonarQube Community rules (the SonarLint engine) were run on the files
this PR touches, and only issues on lines it adds are listed.

- **Fixed** in `9eb94ea1`: `node:` specifiers (S7772).
- **Kept on purpose:**
- S7773 wants `Number.isFinite`/`Number.parseInt` for the `isFinite` and
`parseInt` handed to the `vm` sandbox, but `app.js` calls the global
functions, so those are the ones it needs.
- S1523 flags `vm.runInContext`, which is how the frontend unit tests
load the repo's own `public/app.js`.

## Checked against the repo rules

- Rebased onto `master` (`673442cc`). Each red commit was re-run and
still fails on its assertions; each green commit passes (TDD.md).
- `make test`: all 16 modules pass. Frontend suites: all pass except
`test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME`
and fails the same way on `master` locally (#2160 addresses it).
- No private data: test fixtures and examples use the codes the existing
tests use (SJC/SFO/LAX, `#be`-style scopes).
- No new `map[string]interface{}`, no hard-coded colours. `git add` was
explicit, and there is one logical change per commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:21:30 +02:00

92 lines
3.7 KiB
JavaScript

/* test-nav-stats-throttle.js
*
* The nav bar's packet/node/observer counts were refreshed on a 15 s timer AND
* after every batch of live WebSocket packets, while the /stats client cache
* was cleared every 5 s during traffic. Every open tab, visible or not,
* therefore asked /api/stats about every 4-5 s. On one production instance
* that was 135,654 of 187,411 API requests in a day, about 20,000 per browser.
*
* createVisibleThrottle() gates those refreshes: at most one per interval,
* none while the tab is hidden, and one on return to a visible tab once the
* interval has passed. This test loads the real public/app.js in a vm context.
*/
'use strict';
const vm = require('node:vm');
const fs = require('node:fs');
const assert = require('node:assert');
function loadApp() {
const ctx = {
console, Date, Math, Promise, Error, isFinite, parseInt, JSON, Map, Set,
performance: { now: () => 0 },
window: {},
document: { readyState: 'complete', hidden: false, body: { appendChild: () => {} }, createElement: () => ({ style: {} }) },
fetch: async () => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) }),
setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {},
};
vm.createContext(ctx);
try { vm.runInContext(fs.readFileSync('public/app.js', 'utf8'), ctx); } catch (e) { /* DOM-only code past the helpers */ }
assert.strictEqual(typeof ctx.createVisibleThrottle, 'function', 'createVisibleThrottle must be defined at top level');
return ctx;
}
let passed = 0;
function test(name, fn) { fn(); passed++; console.log(' ✅ ' + name); }
const ctx = loadApp();
function harness(minIntervalMs) {
const state = { t: 1000000, hidden: false, calls: 0 };
const trigger = ctx.createVisibleThrottle(() => { state.calls++; }, {
minIntervalMs, now: () => state.t, isHidden: () => state.hidden,
});
return { state, trigger };
}
console.log('\n=== createVisibleThrottle (nav stats) ===');
test('a burst of triggers within the interval runs the refresh once', () => {
const { state, trigger } = harness(15000);
for (let i = 0; i < 40; i++) { trigger(); state.t += 250; } // 10 s of WS batches
assert.strictEqual(state.calls, 1);
});
test('runs again once the interval has passed', () => {
const { state, trigger } = harness(15000);
trigger();
state.t += 15000;
trigger();
assert.strictEqual(state.calls, 2);
});
test('an hour of WS batches and timer ticks in a visible tab costs at most 241 refreshes', () => {
const { state, trigger } = harness(15000);
for (let s = 0; s < 3600 * 4; s++) { trigger(); state.t += 250; }
assert.ok(state.calls <= 241, 'got ' + state.calls);
});
test('never runs while the tab is hidden', () => {
const { state, trigger } = harness(15000);
state.hidden = true;
for (let s = 0; s < 3600; s++) { trigger(); state.t += 1000; }
assert.strictEqual(state.calls, 0);
});
test('runs on return to a visible tab once the interval has passed', () => {
const { state, trigger } = harness(15000);
trigger(); // visible: 1
state.hidden = true;
state.t += 60000; trigger(); // hidden: skipped
state.hidden = false;
trigger(); // back: interval long passed, runs
assert.strictEqual(state.calls, 2);
});
test('app.js routes the nav stats refresh through the throttle', () => {
const src = fs.readFileSync('public/app.js', 'utf8');
assert.ok(/createVisibleThrottle\(\s*updateNavStats/.test(src), 'updateNavStats should be wrapped by createVisibleThrottle');
assert.ok(!/setInterval\(\s*updateNavStats\s*,/.test(src), 'the timer should call the throttled trigger, not updateNavStats directly');
});
console.log('\n' + passed + ' passed');