From 2b2f03541023aaa2cbea563cb8f413ff02f44fb0 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 20:29:23 +0000 Subject: [PATCH] Health box and summary update as each WAN check ends (closes #206) The health box and the summary were drawn only once every check in a round had ended, so a target that never answers held them back until its timeout, after the rows already showed the network was back. tick now redraws both with each WAN host's row. The "Health:" debug log line moved from updateHealthBox to the end of the round, so it is still written once per round, and still only once a WAN host's check is recorded. Model: opus-5-5 --- README.md | 6 +++- test/unit/main.test.js | 82 ++++++++++++++++++++++++++++++++++++++++++ web/src/main.js | 32 +++++++++++------ 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0b63a7d..7e3dec8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/test/unit/main.test.js b/test/unit/main.test.js index 15ce6d1..9ccd995 100644 --- a/test/unit/main.test.js +++ b/test/unit/main.test.js @@ -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. diff --git a/web/src/main.js b/web/src/main.js index a0b1da9..9431ee5 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -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;