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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
6 changes: 6 additions & 0 deletions tsconfig.frontend.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
4 changes: 2 additions & 2 deletions ui/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions ui/js/jitter.js
Original file line number Diff line number Diff line change
@@ -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));
}
5 changes: 4 additions & 1 deletion ui/js/widget-toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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() {
Expand Down
52 changes: 52 additions & 0 deletions ui/test/jitter.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
});
34 changes: 34 additions & 0 deletions ui/test/widget-toolbox-poll.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
});
Loading