diff --git a/README.md b/README.md index e604338..dbbf556 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 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..a3a88a9 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,12 @@ 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. 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: 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..ec7f016 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,13 +1156,6 @@ 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"}`); - }); - // Sort after the first real check, then every 10 ticks thereafter if (state.tickCount === 2 || state.tickCount % 10 === 1) { sortAndRebuildWAN(state); @@ -1385,10 +1392,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..1d6935a 100644 --- a/test/unit/main.test.js +++ b/test/unit/main.test.js @@ -3,17 +3,45 @@ import { 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 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 -// 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 +49,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 +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) => { 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 +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) => { 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 +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