Compare commits
1
Commits
next
...
2b51eb15bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b51eb15bb |
@@ -104,9 +104,12 @@ 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 redraws every
|
||||||
false outage)
|
row, the summary and the health box once the last check ends. The rows are
|
||||||
|
sorted then too, after the first round that is not discarded and every tenth
|
||||||
|
round after that. 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
|
||||||
@@ -144,8 +147,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,13 @@ 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. 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: `backend/script/lint` says what went wrong with its
|
- 2026-10-03: `backend/script/lint` says what went wrong with its
|
||||||
`.golangci.yml` check (issue #34). On a hash mismatch it says to compare the
|
`.golangci.yml` check (issue #34). On a hash mismatch it says to compare the
|
||||||
file with the org standard: if they differ, restore the org standard; if they
|
file with the org standard: if they differ, restore the org standard; if they
|
||||||
|
|||||||
+27
-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,12 +1156,9 @@ async function tick(state, signal, onOffline) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.allHosts.forEach((host, i) => {
|
// Redraw every row: if the user paused and resumed during this round,
|
||||||
const r = results[i];
|
// rows whose check ended before the resume still read "paused"
|
||||||
host.pushSample(ts, r);
|
state.allHosts.forEach((host, i) => updateHostRow(host, i));
|
||||||
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) {
|
||||||
@@ -1213,7 +1224,7 @@ function stopRecoveryProbe(state) {
|
|||||||
|
|
||||||
// --- Pause / Resume ----------------------------------------------------------
|
// --- Pause / Resume ----------------------------------------------------------
|
||||||
|
|
||||||
function greyOutUI(state) {
|
export function greyOutUI(state) {
|
||||||
// Grey out all host rows
|
// Grey out all host rows
|
||||||
state.allHosts.forEach((host, i) => {
|
state.allHosts.forEach((host, i) => {
|
||||||
const latencyEl = document.querySelector(
|
const latencyEl = document.querySelector(
|
||||||
@@ -1385,10 +1396,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();
|
||||||
|
|||||||
+172
-12
@@ -1,19 +1,70 @@
|
|||||||
// Unit tests for src/main.js, run by script/frontend-test with Node's
|
// 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.
|
// 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 assert from "node:assert/strict";
|
||||||
import { CONFIG, measureLatency } from "../../src/main.js";
|
import {
|
||||||
|
AppState,
|
||||||
|
CONFIG,
|
||||||
|
greyOutUI,
|
||||||
|
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 or greyOutUI 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. As on a page, writing its text replaces its markup; the
|
||||||
|
// status dot greyOutUI looks for in it is not there. 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,
|
||||||
|
querySelector: () => null,
|
||||||
|
set textContent(text) {
|
||||||
|
this.innerHTML = text;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was last written into the status text in host's row.
|
||||||
|
function statusText(state, host) {
|
||||||
|
const index = state.allHosts.indexOf(host);
|
||||||
|
return elements[`.status-text[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 +72,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 +95,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 +106,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 +117,110 @@ 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// In the next three tests, the answering target's check is still waiting
|
||||||
|
// when something happens after which its result must not show.
|
||||||
|
|
||||||
|
test("at a 30000ms interval, a check still waiting when its round is given up 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 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", 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(1000);
|
||||||
|
assert.equal(await settled(round), "still waiting");
|
||||||
|
// The user pauses, which greys out every row, and resumes, which leaves
|
||||||
|
// the rows as they are, as togglePause does. The answering target's
|
||||||
|
// check has already ended, so only the redraw of every row at the end
|
||||||
|
// of the round can take "paused" out of its row.
|
||||||
|
state.paused = true;
|
||||||
|
greyOutUI(state);
|
||||||
|
state.paused = false;
|
||||||
|
assert.equal(statusText(state, answering), "paused");
|
||||||
|
|
||||||
|
// The round ends when the last check times out.
|
||||||
|
t.mock.timers.tick(CONFIG.requestTimeout - 1000);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
for (const host of state.allHosts) {
|
||||||
|
assert.notEqual(statusText(state, host), "paused", host.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user