Each target's row shows its result as soon as its check ends (closes #91)
check / check (push) Successful in 1m37s

tick drew no row until the round's slowest check ended, up to 24
seconds at a 30-second interval since checks time out at 80% of it.
Each check now pushes its sample and redraws its row as it ends;
sorting, the summary, the health box and offline detection still run
once, after the last check. A check that ends while paused or after its
round is given up draws nothing, and the first round is still discarded
as a whole. The row is looked up when the check ends, as a pin click
can re-sort the rows mid-round.

The new unit test runs tick on the mocked clock against a stand-in
page.

Model: opus-5-5
This commit is contained in:
2026-10-03 13:58:44 +00:00
parent 0ab6418e3d
commit 88e0d5116f
4 changed files with 102 additions and 32 deletions
+6 -5
View File
@@ -102,9 +102,10 @@ code lives in `src/main.js` with a class-based architecture:
color-coded line segments, error regions, and DPR-aware scaling color-coded line segments, error regions, and DPR-aware scaling
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` / - **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` /
`updateSummary()` / `updateHealthBox()` handle incremental updates `updateSummary()` / `updateHealthBox()` handle incremental updates
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, - **`tick()`**: Main loop — measures all hosts in parallel, pushing each host's
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no sample and redrawing its row as soon as its check ends, then sorts and redraws
false outage) the summary and health box once the last check ends. When paused, pushes blank
markers (no probes, no false outage)
- **`Reporter`**: Posts collected samples to the backend - **`Reporter`**: Posts collected samples to the backend
### Reporting ### Reporting
@@ -142,8 +143,8 @@ have all finished before the next round is due. When no WAN host answers, a
recovery probe checks 4 random WAN hosts every half second, giving up the checks recovery probe checks 4 random WAN hosts every half second, giving up the checks
it started half a second before. As soon as one answers, a new round starts at it started half a second before. As soon as one answers, a new round starts at
once, as it does after an interval change. A round started early gives up the once, as it does after an interval change. A round started early gives up the
last round's checks if they are still waiting, and that round records nothing, last round's checks if they are still waiting, and that round records nothing
so rounds never overlap. IPv4 only. more, so rounds never overlap. IPv4 only.
### Color coding ### Color coding
+6
View File
@@ -23,6 +23,12 @@ latest run passes.
# Completed Steps # Completed Steps
- 2026-10-03: each target's row shows its result as soon as its check ends
(issue #91), where every row waited for the round's slowest check, up to 24
seconds at a 30-second interval. Sorting, the summary, the health box and
offline detection still run once, when the round's last check ends. A check
that ends after the user pauses or after its round is given up shows nothing,
and the first round is still discarded as a whole
- 2026-10-03: each target check times out after 80% of the refresh interval - 2026-10-03: each target check times out after 80% of the refresh interval
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A (issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
round started early, after an interval change or when the recovery probe finds round started early, after an interval change or when the recovery probe finds
+23 -16
View File
@@ -263,7 +263,7 @@ class HostState {
} }
} }
class AppState { export class AppState {
constructor(localHosts) { constructor(localHosts) {
this.wan = WAN_HOSTS.map( this.wan = WAN_HOSTS.map(
(h) => new HostState(h, h.name === "datavi.be"), (h) => new HostState(h, h.name === "datavi.be"),
@@ -1105,7 +1105,7 @@ function sortAndRebuildWAN(state) {
// --- Main Loop --------------------------------------------------------------- // --- Main Loop ---------------------------------------------------------------
async function tick(state, signal, onOffline) { export async function tick(state, signal, onOffline) {
const ts = Date.now(); const ts = Date.now();
if (state.paused) { if (state.paused) {
@@ -1126,12 +1126,26 @@ async function tick(state, signal, onOffline) {
log.debug(`Tick #${state.tickCount + 1} started`); log.debug(`Tick #${state.tickCount + 1} started`);
const results = await Promise.all( // Each host's row shows its result as soon as its check ends. The
state.allHosts.map((h) => measureLatency(h.url, signal)), // result is discarded if by then the user has paused or the next round
// has given up this one's checks, and in the first tick (tickCount is
// still 0), which is discarded as a whole below. The row is looked up
// when the check ends, as a pin click may have re-sorted the rows since
// the round started.
await Promise.all(
state.allHosts.map(async (host) => {
const r = await measureLatency(host.url, signal);
if (state.paused || signal.aborted || state.tickCount === 0) {
return;
}
host.pushSample(ts, r);
updateHostRow(host, state.allHosts.indexOf(host));
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
}),
); );
// User may have paused, or the next round may have given up this // User may have paused, or the next round may have given up this
// one's checks, while awaiting results — discard them // one's checks, while awaiting results — skip the rest of the round
if (state.paused || signal.aborted) return; if (state.paused || signal.aborted) return;
state.tickCount++; state.tickCount++;
@@ -1142,13 +1156,6 @@ async function tick(state, signal, onOffline) {
return; return;
} }
state.allHosts.forEach((host, i) => {
const r = results[i];
host.pushSample(ts, r);
updateHostRow(host, i);
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
});
// Sort after the first real check, then every 10 ticks thereafter // Sort after the first real check, then every 10 ticks thereafter
if (state.tickCount === 2 || state.tickCount % 10 === 1) { if (state.tickCount === 2 || state.tickCount % 10 === 1) {
sortAndRebuildWAN(state); sortAndRebuildWAN(state);
@@ -1385,10 +1392,10 @@ async function init() {
setInterval(updateClocks, 1000); setInterval(updateClocks, 1000);
// Rounds never overlap: a round first gives up the last round's checks // Rounds never overlap: a round first gives up the last round's checks
// if they are still waiting, and the last round then records nothing. // if they are still waiting, and the last round then records nothing
// At a steady interval they never are, as they time out at 80% of it; // more. At a steady interval they never are, as they time out at 80% of
// they can be when a round starts early, after an interval change or // it; they can be when a round starts early, after an interval change
// when the recovery probe finds a target answering. // or when the recovery probe finds a target answering.
let roundChecks = new AbortController(); let roundChecks = new AbortController();
function doTick() { function doTick() {
roundChecks.abort(); roundChecks.abort();
+67 -11
View File
@@ -3,17 +3,45 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { CONFIG, measureLatency } from "../../src/main.js"; import { AppState, CONFIG, measureLatency, tick } from "../../src/main.js";
// measureLatency writes timeouts to the debug log, which looks for its // There is no page here, so the tests stand in for it. The debug log looks
// panel in the page. There is no page here. // for its panel by id and finds none. Each element of a host's row that
globalThis.document = { getElementById: () => null }; // tick draws into is a plain object, made the first time it is looked up
// and kept in elements under its selector. Drawing a sparkline does
// nothing; it looks for the pixel ratio on window and finds none.
const elements = {};
const doNothing = () => {};
const canvasContext = {
clearRect: doNothing,
beginPath: doNothing,
moveTo: doNothing,
lineTo: doNothing,
stroke: doNothing,
fill: doNothing,
fillRect: doNothing,
fillText: doNothing,
arc: doNothing,
};
globalThis.window = {};
globalThis.document = {
getElementById: () => null,
querySelector: (selector) =>
(elements[selector] ??= { getContext: () => canvasContext }),
};
// What tick last wrote into the latency figure in host's row, or undefined
// if it has written nothing there.
function latencyFigure(state, host) {
const index = state.allHosts.indexOf(host);
return elements[`.latency-value[data-host="${index}"]`]?.innerHTML;
}
// Mocks the clock for test t, so that a check lasting seconds takes no real // Mocks the clock for test t, so that a check lasting seconds takes no real
// time, and replaces fetch with a target that answers after answerAfter // time, and replaces fetch with targets that each answer after
// milliseconds of that clock, or never when answerAfter is Infinity. Both // answerAfter(url) milliseconds of that clock, or never when that is
// are restored when the test ends. // Infinity. Both are restored when the test ends.
function mockTarget(t, answerAfter) { function mockTargets(t, answerAfter) {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
t.mock.method(performance, "now", () => Date.now()); t.mock.method(performance, "now", () => Date.now());
t.mock.method( t.mock.method(
@@ -21,7 +49,9 @@ function mockTarget(t, answerAfter) {
"fetch", "fetch",
(url, { signal }) => (url, { signal }) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (answerAfter !== Infinity) setTimeout(resolve, answerAfter); if (answerAfter(url) !== Infinity) {
setTimeout(resolve, answerAfter(url));
}
signal.addEventListener("abort", () => reject(signal.reason)); signal.addEventListener("abort", () => reject(signal.reason));
}), }),
); );
@@ -42,7 +72,7 @@ for (const interval of [10000, 30000]) {
test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => { test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => {
CONFIG.updateInterval = interval; CONFIG.updateInterval = interval;
mockTarget(t, slowAnswer); mockTargets(t, () => slowAnswer);
const check = measureLatency("https://target.test"); const check = measureLatency("https://target.test");
t.mock.timers.tick(slowAnswer); t.mock.timers.tick(slowAnswer);
assert.deepEqual(await settled(check), { assert.deepEqual(await settled(check), {
@@ -53,7 +83,7 @@ for (const interval of [10000, 30000]) {
test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => { test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => {
CONFIG.updateInterval = interval; CONFIG.updateInterval = interval;
mockTarget(t, Infinity); mockTargets(t, () => Infinity);
const check = measureLatency("https://target.test"); const check = measureLatency("https://target.test");
t.mock.timers.tick(timeout - 1); t.mock.timers.tick(timeout - 1);
assert.equal(await settled(check), "still waiting"); assert.equal(await settled(check), "still waiting");
@@ -64,3 +94,29 @@ for (const interval of [10000, 30000]) {
}); });
}); });
} }
test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => {
CONFIG.updateInterval = 30000;
const state = new AppState([
{ name: "Answering", url: "https://answering.test" },
]);
const answering = state.local[0];
const waiting = state.wan[0];
// No target but the answering one ever answers.
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
// The third tick: the first is discarded as a whole, and the second ends
// by sorting the rows, which rebuilds a page that is not here.
state.tickCount = 2;
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(1000);
assert.equal(await settled(round), "still waiting");
assert.match(latencyFigure(state, answering), />1000</);
assert.equal(latencyFigure(state, waiting), undefined);
assert.equal(state.tickCount, 2);
// The round ends, once, when the last check times out.
t.mock.timers.tick(CONFIG.requestTimeout - 1000);
assert.notEqual(await settled(round), "still waiting");
assert.equal(state.tickCount, 3);
});