Each target's row shows its result as soon as its check ends (closes #91)
check / check (push) Successful in 1m37s
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user