From 6126a7f85cf26d86b627e729b8ec754339295cbb Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 3 Oct 2026 13:58:44 +0000 Subject: [PATCH] Each target's row shows its result as soon as its check ends (closes #91) 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. When the last check ends, every row is redrawn, so none still reads "paused" after a pause and resume, and sorting, the summary, the health box and offline detection run once. 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. Unit tests run tick on the mocked clock against a stand-in page. Model: opus-5-5 --- README.md | 11 ++-- TODO.md | 7 +++ src/main.js | 41 ++++++++----- test/unit/main.test.js | 136 +++++++++++++++++++++++++++++++++++++---- 4 files changed, 163 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e604338..ec3eac9 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,10 @@ code lives in `src/main.js` with a class-based architecture: color-coded line segments, error regions, and DPR-aware scaling - **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` / `updateSummary()` / `updateHealthBox()` handle incremental updates -- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, - pushes samples, redraws UI. When paused, pushes blank markers (no probes, no - false outage) +- **`tick()`**: Main loop — measures all hosts in parallel, pushing each host's + sample and redrawing its row as soon as its check ends, then redraws every + row, sorts, and redraws 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 ### Reporting @@ -144,8 +145,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 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 -last round's checks if they are still waiting, and that round records nothing, -so rounds never overlap. IPv4 only. +last round's checks if they are still waiting, and that round records nothing +more, so rounds never overlap. IPv4 only. ### Color coding diff --git a/TODO.md b/TODO.md index 536f37f..6a0fc6e 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,13 @@ latest run passes. # 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. Every row is still redrawn, and sorting, the + summary, the health box and offline detection still run, once, when the + round's last check ends, so no row reads "paused" after a pause and resume + during the round. 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: the Go tests run with the race detector and coverage (issue #88): `backend/script/test` runs `go test -timeout 30s -race -cover ./...` and, if that fails, runs it again with `-v` and fails. Go's `-timeout` bounds the diff --git a/src/main.js b/src/main.js index 82f1dbd..fa01fba 100644 --- a/src/main.js +++ b/src/main.js @@ -263,7 +263,7 @@ class HostState { } } -class AppState { +export class AppState { constructor(localHosts) { this.wan = WAN_HOSTS.map( (h) => new HostState(h, h.name === "datavi.be"), @@ -1105,7 +1105,7 @@ function sortAndRebuildWAN(state) { // --- Main Loop --------------------------------------------------------------- -async function tick(state, signal, onOffline) { +export async function tick(state, signal, onOffline) { const ts = Date.now(); if (state.paused) { @@ -1126,12 +1126,26 @@ async function tick(state, signal, onOffline) { log.debug(`Tick #${state.tickCount + 1} started`); - const results = await Promise.all( - state.allHosts.map((h) => measureLatency(h.url, signal)), + // Each host's row shows its result as soon as its check ends. The + // 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 - // 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; state.tickCount++; @@ -1142,12 +1156,9 @@ async function tick(state, signal, onOffline) { 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"}`); - }); + // Redraw every row: if the user paused and resumed during this round, + // rows whose check ended before the resume still read "paused" + state.allHosts.forEach((host, i) => updateHostRow(host, i)); // Sort after the first real check, then every 10 ticks thereafter if (state.tickCount === 2 || state.tickCount % 10 === 1) { @@ -1385,10 +1396,10 @@ async function init() { setInterval(updateClocks, 1000); // 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. - // At a steady interval they never are, as they time out at 80% of it; - // they can be when a round starts early, after an interval change or - // when the recovery probe finds a target answering. + // if they are still waiting, and the last round then records nothing + // more. At a steady interval they never are, as they time out at 80% of + // it; they can be when a round starts early, after an interval change + // or when the recovery probe finds a target answering. let roundChecks = new AbortController(); function doTick() { roundChecks.abort(); diff --git a/test/unit/main.test.js b/test/unit/main.test.js index c49ef63..e7e1084 100644 --- a/test/unit/main.test.js +++ b/test/unit/main.test.js @@ -1,19 +1,51 @@ // Unit tests for src/main.js, run by script/frontend-test with Node's // built-in test runner. Importing the module does not start the page. -import { test } from "node:test"; +import { beforeEach, test } from "node:test"; 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 -// panel in the page. There is no page here. -globalThis.document = { getElementById: () => null }; +// There is no page here, so the tests stand in for it. The debug log looks +// for its panel by id and finds none. Each element of a host's row that +// tick draws into is a plain object, made the first time a test looks it +// up and kept in elements under its selector until the next test starts. +// Drawing a sparkline does nothing; it looks for the pixel ratio on window +// and finds none. +let elements; +beforeEach(() => { + 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 -// time, and replaces fetch with a target that answers after answerAfter -// milliseconds of that clock, or never when answerAfter is Infinity. Both -// are restored when the test ends. -function mockTarget(t, answerAfter) { +// time, and replaces fetch with targets that each answer after +// answerAfter(url) milliseconds of that clock, or never when that is +// Infinity. Both are restored when the test ends. +function mockTargets(t, answerAfter) { t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); t.mock.method(performance, "now", () => Date.now()); t.mock.method( @@ -21,7 +53,9 @@ function mockTarget(t, answerAfter) { "fetch", (url, { signal }) => new Promise((resolve, reject) => { - if (answerAfter !== Infinity) setTimeout(resolve, answerAfter); + if (answerAfter(url) !== Infinity) { + setTimeout(resolve, answerAfter(url)); + } signal.addEventListener("abort", () => reject(signal.reason)); }), ); @@ -42,7 +76,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) => { CONFIG.updateInterval = interval; - mockTarget(t, slowAnswer); + mockTargets(t, () => slowAnswer); const check = measureLatency("https://target.test"); t.mock.timers.tick(slowAnswer); assert.deepEqual(await settled(check), { @@ -53,7 +87,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) => { CONFIG.updateInterval = interval; - mockTarget(t, Infinity); + mockTargets(t, () => Infinity); const check = measureLatency("https://target.test"); t.mock.timers.tick(timeout - 1); assert.equal(await settled(check), "still waiting"); @@ -64,3 +98,81 @@ 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 { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); + state.tickCount = 2; + + const roundChecks = new AbortController(); + const round = tick(state, roundChecks.signal); + t.mock.timers.tick(500); + // As a round started early does to the last round's checks. + roundChecks.abort(); + assert.notEqual(await settled(round), "still waiting"); + assert.equal(latencyFigure(state, answering), undefined); +}); + +test("at a 30000ms interval, a check still waiting when the user pauses does not show in its row", async (t) => { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); + state.tickCount = 2; + + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(500); + state.paused = true; + t.mock.timers.tick(500); + assert.equal(await settled(round), "still waiting"); + assert.equal(latencyFigure(state, answering), undefined); +}); + +test("at a 30000ms interval, a check in the first round does not show in its row", async (t) => { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); + + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(1000); + assert.equal(await settled(round), "still waiting"); + assert.equal(latencyFigure(state, answering), undefined); +});