From e43633332f4d358f4c30c06b5b5441b08bd15656 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:15:12 -0400 Subject: [PATCH] Spread widget polling the way the dashboard already spreads its own The widget toolbox scheduled each repeat on an exact interval, so widgets opened together kept fetching on the same tick. The dashboard shell had solved this for its own polls with a local helper. Move that helper into ui/js/jitter.js and use it in both. The first fetch stays immediate: that one is the widget's content. --- CHANGELOG.md | 3 ++ docs/widgets.md | 3 ++ tsconfig.frontend.json | 6 ++++ ui/js/dashboard.js | 4 +-- ui/js/jitter.js | 14 ++++++++ ui/js/widget-toolbox.js | 5 ++- ui/test/jitter.test.mjs | 52 ++++++++++++++++++++++++++++ ui/test/widget-toolbox-poll.test.mjs | 34 ++++++++++++++++++ 8 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 ui/js/jitter.js create mode 100644 ui/test/jitter.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ae726d..ecf6d3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Widgets spread their polling, so several on one dashboard no longer fetch from + your services on the same tick. + - Importing from another dashboard reads a list written level with its key, a value folded across lines, and an empty `[]` or `{}`, each of which used to refuse the whole file. diff --git a/docs/widgets.md b/docs/widgets.md index d7d11347..dc7a8b47 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -599,6 +599,9 @@ A failure keeps the last good render in place; only after `staleAfter` (default 2) consecutive failures does it surface `errorText` with how long ago the last success was. `sinceLabel(ts)` gives that "3m ago" label on its own. +The first fetch runs immediately. Each repeat is spread by up to 15%, so several +widgets on one dashboard do not fetch on the same tick. + **Links** - `openUrl(href)` opens a link in a new tab. Use this rather than `window.open`, diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index db9c26cf..a04e22f8 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -139,6 +139,12 @@ "/js/import-foreign.js": [ "./ui/js/import-foreign.js" ], + "/js/jitter.js": [ + "./ui/js/jitter.js" + ], + "/js/jitter.js?v=*": [ + "./ui/js/jitter.js" + ], "/js/import-foreign.js?v=*": [ "./ui/js/import-foreign.js" ], diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 57e03c60..7ab55e08 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -35,6 +35,7 @@ import { restorePage, } from '/js/dashboard-logic.js?v=640430ba'; import { trapFocus } from '/js/dialog.js?v=b3841546'; +import { jitter } from '/js/jitter.js?v=1'; const MOB = innerWidth <= 768 || /iPhone|iPod|Android/i.test(navigator.userAgent); @@ -715,14 +716,13 @@ async function boot() { /* Jittered, so several open clients do not poll on the same tick. */ let _pollTimers = []; - const _jit = base => Math.round(base * (1 + (Math.random() * 2 - 1) * 0.15)); const _repeat = (fn, base) => { let h; const tick = async () => { try { await fn(); } catch {} - h = setTimeout(tick, _jit(base)); + h = setTimeout(tick, jitter(base)); }; h = setTimeout(tick, Math.round(Math.random() * base)); _pollTimers.push(() => clearTimeout(h)); diff --git a/ui/js/jitter.js b/ui/js/jitter.js new file mode 100644 index 00000000..2e57ba5b --- /dev/null +++ b/ui/js/jitter.js @@ -0,0 +1,14 @@ +// @ts-check +/* Spread repeated timers, so several pollers started together do not keep + firing on the same tick. Keep it dependency-free: it is imported inside + widget frames as well as by the dashboard. */ + +const SPREAD = 0.15; + +/** A delay within ±15% of `base`. + @param {number} base milliseconds @returns {number} */ +export function jitter(base) { + const n = Number(base); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.round(n * (1 + (Math.random() * 2 - 1) * SPREAD)); +} diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index ae62b56e..cc7acbef 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -3,6 +3,7 @@ import { esc, html, setHtml } from '/js/html.js?v=ccec347c'; import { isSafeLinkUrl } from '/js/link-url.js?v=19038560'; +import { jitter } from '/js/jitter.js?v=1'; export { esc, html, setHtml }; @@ -348,7 +349,9 @@ export function poll(opts = {}) { paused = true; return; } - timer = setTimeout(loop, intervalFor(lastData)); + /* Jittered, so several widgets on one dashboard do not fetch in lockstep. + The first tick is not delayed: that one is the widget's content. */ + timer = setTimeout(loop, jitter(intervalFor(lastData))); } function onVisibility() { diff --git a/ui/test/jitter.test.mjs b/ui/test/jitter.test.mjs new file mode 100644 index 00000000..dbd799a4 --- /dev/null +++ b/ui/test/jitter.test.mjs @@ -0,0 +1,52 @@ +/* Several pollers started at the same moment keep firing at the same moment, + which lands on a service in bursts. The dashboard already spread its own + polls; widgets polled on an exact cadence. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { jitter } from '../js/jitter.js'; + +const withRandom = (value, fn) => { + const real = Math.random; + Math.random = () => value; + try { + return fn(); + } finally { + Math.random = real; + } +}; + +test('the delay stays within 15% of the interval, either way', () => { + assert.equal( + withRandom(0, () => jitter(1000)), + 850, + ); + assert.equal( + withRandom(1, () => jitter(1000)), + 1150, + ); + assert.equal( + withRandom(0.5, () => jitter(1000)), + 1000, + ); +}); + +test('every delay across many draws stays inside the band', () => { + for (let i = 0; i < 500; i++) { + const d = jitter(30_000); + assert.ok(d >= 25_500 && d <= 34_500, `${d} is outside ±15%`); + } +}); + +test('two pollers on the same interval do not keep the same delay', () => { + const draws = new Set(Array.from({ length: 50 }, () => jitter(30_000))); + assert.ok(draws.size > 1, 'a fixed delay would defeat the point'); +}); + +/* A caller that has no interval yet must not schedule into the past or be + handed NaN, which setTimeout treats as zero and turns into a busy loop. */ +test('an unusable interval gives zero, not NaN', () => { + for (const v of [0, -5, NaN, undefined, null, 'soon', {}]) { + assert.equal(jitter(v), 0, String(v)); + } +}); diff --git a/ui/test/widget-toolbox-poll.test.mjs b/ui/test/widget-toolbox-poll.test.mjs index db4ca333..b6808742 100644 --- a/ui/test/widget-toolbox-poll.test.mjs +++ b/ui/test/widget-toolbox-poll.test.mjs @@ -259,3 +259,37 @@ test('stop while hidden stays stopped when the tab returns', async () => { assert.equal(calls, after, 'a stopped poll must not resurrect on visibility'); }); }); + +/* Several widgets open together polled on the same exact cadence, so they kept + hitting the same services on the same tick. */ +test('the repeat interval is spread, and the first fetch is not delayed', async () => { + const delays = []; + const realTimeout = globalThis.setTimeout; + globalThis.setTimeout = (fn, ms, ...rest) => { + delays.push(ms); + return realTimeout(fn, ms, ...rest); + }; + try { + await run(async (_dom, start) => { + const first = Date.now(); + let firstFetchAt = 0; + start({ + interval: 1000, + fetch: async () => { + firstFetchAt = firstFetchAt || Date.now(); + return {}; + }, + }); + await tick(20); + assert.ok(firstFetchAt - first < 50, 'the first fetch waits for nothing'); + }); + } finally { + globalThis.setTimeout = realTimeout; + } + + const scheduled = delays.filter(d => d > 500); + assert.ok(scheduled.length > 0, 'the repeat was never scheduled'); + for (const d of scheduled) { + assert.ok(d >= 850 && d <= 1150, `${d}ms is outside ±15% of the interval`); + } +});