Health box and summary update as each WAN check ends (closes #206) #217

Merged
clawbot merged 1 commits from issue-206-health-box-per-check into next 2026-11-11 22:40:55 +01:00
3 changed files with 108 additions and 12 deletions

No files matched your search

+5 -1
View File
@@ -107,6 +107,9 @@ Below it, a summary line covers the WAN hosts only:
history
- `Checks`: the rounds run since the page loaded or the interval changed
The health box and the summary take in a WAN host's result as soon as its row
shows it.
When no WAN host answers, the page checks 4 of them, picked at random, every
half second. As soon as one answers, a new round starts.
@@ -363,7 +366,8 @@ The page is built with Vite and Tailwind CSS v4. All its code is in
- `HostState` and `AppState`: one host's history and status, and the whole
page's state.
- `measureLatency`: one check. `tick`: one round, which checks every host in
parallel and draws each row as soon as its check ends.
parallel. It draws each row as soon as its check ends, and with a WAN host's
row also the summary and the health box.
- `SparklineRenderer`: the graphs. `buildUI`, `updateHostRow`, `updateSummary`
and `updateHealthBox`: the page and its parts.
- `Reporter`: builds and posts the reports. `buildReport` is a pure function of
+82
View File
@@ -868,6 +868,88 @@ test("once every WAN target's check has failed, the summary's Reachable slot rea
assert.equal(elements["#summary-reachable"].className, "text-red-400");
});
// The network has just come back: every WAN target's last check failed, so
// the health box reads OFFLINE. In this round every WAN target but one
// answers after 10ms, and the one never answers. Each element of the summary
// and the health box is a plain object, as in the tests above; the debug
// log's panel is not there.
test("at a 30000ms interval, the summary and the health box take in each WAN target's answer while another WAN target's check is still waiting", async (t) => {
CONFIG.updateInterval = 30000;
t.mock.method(globalThis.document, "getElementById", (id) =>
id.startsWith("summary-") || id.startsWith("health-")
? (elements[`#${id}`] ??= {})
: null,
);
const state = new AppState([]);
for (const host of state.wan) {
host.pushSample(Date.now(), { latency: null, error: "timeout" });
}
const silent = state.wan[0];
mockTargets(t, (url) => (url === silent.url ? Infinity : 10));
// The third tick, as in the tests above.
state.tickCount = 2;
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(10);
assert.equal(await settled(round), "still waiting");
assert.equal(elements["#health-text"].textContent, "HEALTHY");
assert.equal(
elements["#summary-reachable"].textContent,
`${state.wan.length - 1}/${state.wan.length}`,
);
assert.equal(elements["#summary-max"].textContent, "10");
// Checks counts finished rounds only.
assert.equal(elements["#summary-checks"].textContent, 2);
t.mock.timers.tick(CONFIG.requestTimeout - 10);
assert.notEqual(await settled(round), "still waiting");
assert.equal(elements["#summary-checks"].textContent, 3);
});
// The user pauses during the first recorded round after the page loads.
// Every WAN target's check ends during the pause. Silent never answers, and
// the user resumes before its check times out. Every element the page looks
// up by id is a plain object, except the debug log's panel, which is not
// there.
test(`at a 30000ms interval, after the user pauses and resumes during a round that records no WAN target's check, the debug log has no "Health:" line`, async (t) => {
CONFIG.updateInterval = 30000;
t.mock.method(globalThis.document, "getElementById", (id) =>
id === "debug-panel" ? null : (elements[`#${id}`] ??= {}),
);
t.mock.method(log, "notice");
t.mock.method(log, "warning");
const state = new AppState([
{ name: "Silent", url: "https://silent.test" },
]);
const silent = state.local[0];
mockTargets(t, (url) => (url === silent.url ? Infinity : 10));
// The second tick: the first that is recorded.
state.tickCount = 1;
const round = tick(state, new AbortController().signal);
// The user pauses, which greys out the summary and the health box, as
// togglePause does.
state.paused = true;
greyOutUI(state);
t.mock.timers.tick(10);
assert.equal(await settled(round), "still waiting");
// The user resumes, which leaves the page as it is.
state.paused = false;
t.mock.timers.tick(CONFIG.requestTimeout - 10);
assert.notEqual(await settled(round), "still waiting");
assert.equal(state.tickCount, 2);
const lines = [...log.notice.mock.calls, ...log.warning.mock.calls].map(
(call) => call.arguments[0],
);
assert.deepEqual(
lines.filter((line) => line.startsWith("Health:")),
[],
);
assert.equal(elements["#health-text"].textContent, "---");
assert.equal(elements["#summary-reachable"].textContent, "--/--");
});
// In the next two tests each of the header's clocks is a plain object, made
// the first time updateClocks looks it up and kept in elements under "#" and
// its id.
+21 -11
View File
@@ -1128,11 +1128,6 @@ function updateHealthBox(state) {
el.textContent = labels[health];
el.className = textColors[health];
box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${boxColors[health]}`;
if (health === "offline" || health === "degraded")
log.warning(`Health: ${labels[health]}`);
else if (health === "slow") log.warning(`Health: ${labels[health]}`);
else if (health === "healthy") log.notice(`Health: ${labels[health]}`);
}
// Writes the local time, with its time zone's name, and the UTC time into
@@ -1303,12 +1298,13 @@ export async function tick(state, signal, onOffline) {
log.debug(`Tick #${state.tickCount + 1} started`);
// 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.
// Each host's row shows its result as soon as its check ends, and a WAN
// host's result goes into the summary and the health box at the same
// time. 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, host.method);
@@ -1318,6 +1314,10 @@ export async function tick(state, signal, onOffline) {
logCheck(host, r);
host.pushSample(ts, r);
updateHostRow(host, state.allHosts.indexOf(host));
if (state.wan.includes(host)) {
updateSummary(state);
updateHealthBox(state);
}
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
}),
);
@@ -1344,9 +1344,19 @@ export async function tick(state, signal, onOffline) {
sortAndRebuildWAN(state);
}
// Redraw the summary and the health box: a pause greys them out too, and
// Checks now counts this round.
updateSummary(state);
updateHealthBox(state);
// Like the health box, the log gives no health until a WAN host's check
// is recorded: a pause may have discarded every WAN check so far.
if (state.wan.some((h) => h.status !== "pending")) {
const health = state.healthStatus();
if (health === "healthy") log.notice("Health: HEALTHY");
else log.warning(`Health: ${health.toUpperCase()}`);
}
const reachable = state.allHosts.filter(
(h) => h.status === "online",
).length;