From 0c6a59a9fa48cb3302a705b123cd51656ccf935d Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 18 Oct 2026 22:34:25 +0000 Subject: [PATCH] Clock errors no longer stop probing; legend reads >=500ms (closes #149, closes #152) The header's clocks start last in init, after the rounds, and an error formatting them, as in a browser that rejects the "shortGeneric" time zone name, leaves both clocks blank instead of stopping the page before its first round. updateClocks moved out of init so a unit test can call it. The footer legend's red entry reads >=500ms, as README.md and CONFIG.latencyColors have it. buildUI is exported so a unit test can draw the page and check the legend against every entry of CONFIG.latencyColors. Model: opus-5-5 --- TODO.md | 12 ++++++ test/unit/main.test.js | 86 +++++++++++++++++++++++++++++++++++++++++- web/src/main.js | 54 ++++++++++++++++---------- 3 files changed, 130 insertions(+), 22 deletions(-) diff --git a/TODO.md b/TODO.md index d2a9f95..ec56ac3 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,18 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of # Completed Steps +- 2026-10-18: a browser that cannot format the header's clocks still probes + ([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the + local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which + Chrome before 95, Firefox before 91 and Safari before 15.4 reject with an + error; it came before the first round started, so such a browser never checked + a target. The clocks now start last, after the rounds, and an error formatting + them leaves both blank. The footer's color legend reads `>=500ms` for red, + where it read `>500ms`, so 500 ms, which is red, is in one of its colors + ([#152](https://git.eeqj.de/sneak/netwatch/issues/152)). Unit tests check the + clocks with a working `Intl.DateTimeFormat` and with one that rejects + `shortGeneric`, and draw the page and check the legend against every entry of + `CONFIG.latencyColors` - 2026-10-18: `netwatch-server prepare-data-dir` no longer changes the owner of a file outside `/data` through a hard link in the volume ([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A file with a second diff --git a/test/unit/main.test.js b/test/unit/main.test.js index 0201e85..be51957 100644 --- a/test/unit/main.test.js +++ b/test/unit/main.test.js @@ -6,6 +6,7 @@ import { beforeEach, test } from "node:test"; import assert from "node:assert/strict"; import { AppState, + buildUI, CONFIG, greyOutUI, hostRowHTML, @@ -17,6 +18,7 @@ import { measureLatency, Reporter, tick, + updateClocks, updateSummary, } from "../../web/src/main.js"; @@ -28,8 +30,10 @@ import { // 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. In each test, console.error and console.info -// print nothing and keep what they are given. +// window and finds none. The commit the page's footer names, which +// vite.config.js fills in when it builds the page, is blank. In each test, +// console.error and console.info print nothing and keep what they are +// given. const doNothing = () => {}; let elements; beforeEach((t) => { @@ -51,6 +55,8 @@ const canvasContext = { globalThis.window = {}; globalThis.requestAnimationFrame = doNothing; globalThis.cancelAnimationFrame = doNothing; +globalThis.__COMMIT_HASH__ = ""; +globalThis.__COMMIT_FULL__ = ""; globalThis.document = { getElementById: () => null, createElement: () => ({}), @@ -579,6 +585,32 @@ test("a check that timed out or found its target unreachable has a grey figure a assert.equal(latencyHex(null), "#6b7280"); }); +// buildUI draws the page into #app, here a plain object. Each entry in the +// footer's color legend is a colored dot and the latencies it stands for, +// with < and > written as < and >. A latency takes the color of the +// first entry in CONFIG.latencyColors whose limit it is below, so the +// legend has, for each of those entries in order, a dot of its color (a +// bg- class where CONFIG.latencyColors has a text- one) and the latencies +// below its limit, or, for the last, which has no limit, the latencies +// from the limit before it up. A gray dot for offline comes after them. +test("the footer's color legend has each entry in CONFIG.latencyColors, with its color and limit, then offline", (t) => { + const app = {}; + t.mock.method(globalThis.document, "getElementById", () => app); + buildUI(new AppState([])); + + const legend = [ + ...app.innerHTML.matchAll(/(bg-\w+-500) [^"]*"><\/span>(\S+)/g), + ].map(([, dot, latencies]) => [dot, latencies]); + const colors = CONFIG.latencyColors; + const expected = colors.map((color, i) => [ + color.className.replace("text-", "bg-"), + color.below === Infinity + ? `>=${colors[i - 1].below}ms` + : `<${color.below}ms`, + ]); + assert.deepEqual(legend, [...expected, ["bg-gray-500", "offline"]]); +}); + // A target whose checks, in turn, answered after each of latencies ms, or, // for null, found it unreachable. function hostAfter(latencies) { @@ -701,6 +733,56 @@ test("once every WAN target's check has failed, the summary's Reachable slot rea assert.equal(elements["#summary-reachable"].className, "text-red-400"); }); +// 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. + +// The mocked clock reads 1970-01-01T00:00:00.000Z. +test("the header's clocks show the local time with its time zone's name, and the UTC time", (t) => { + t.mock.method( + globalThis.document, + "getElementById", + (id) => (elements[`#${id}`] ??= {}), + ); + t.mock.timers.enable({ apis: ["Date"] }); + + updateClocks(); + + assert.match( + elements["#clock-local"].textContent, + /^Local: \d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S/, + ); + assert.equal( + elements["#clock-utc"].textContent, + "UTC: 1970-01-01 00:00:00 UTC", + ); +}); + +// Here Intl.DateTimeFormat is as in Chrome before 95, Firefox before 91 or +// Safari before 15.4: it throws a RangeError when asked for the time zone's +// "shortGeneric" name. +test("in a browser that cannot name the time zone, updating the clocks throws nothing and leaves them blank", (t) => { + t.mock.method( + globalThis.document, + "getElementById", + (id) => (elements[`#${id}`] ??= {}), + ); + const DateTimeFormat = Intl.DateTimeFormat; + t.mock.method(Intl, "DateTimeFormat", function (locales, options) { + if (options?.timeZoneName === "shortGeneric") { + throw new RangeError( + "Value shortGeneric out of range for Intl.DateTimeFormat options property timeZoneName", + ); + } + return new DateTimeFormat(locales, options); + }); + + updateClocks(); + + assert.equal(elements["#clock-local"].textContent, ""); + assert.equal(elements["#clock-utc"].textContent, ""); +}); + // An app state in which, of the WAN targets, the first timedOut timed out, // the next unreachable were found unreachable, the next answered answered // after latency ms, and the rest have not been checked yet. diff --git a/web/src/main.js b/web/src/main.js index ab40d2d..78746da 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -829,7 +829,7 @@ function wanRowsHTML(wanHosts) { return rows.join(""); } -function buildUI(state) { +export function buildUI(state) { const app = document.getElementById("app"); app.innerHTML = `
@@ -911,7 +911,7 @@ function buildUI(state) { <100ms <200ms <500ms - >500ms + >=500ms offline

${__COMMIT_HASH__}

@@ -1114,6 +1114,33 @@ function updateHealthBox(state) { else if (health === "healthy") log.notice(`Health: ${labels[health]}`); } +// Writes the local time, with its time zone's name, and the UTC time into +// the header's clocks. The clocks only decorate the page: in a browser +// whose Intl.DateTimeFormat rejects the "shortGeneric" time zone name +// (Chrome before 95, Firefox before 91, Safari before 15.4) they are left +// blank. +export function updateClocks() { + let local = ""; + let utc = ""; + try { + const now = new Date(); + const zone = new Intl.DateTimeFormat("en-US", { + timeZoneName: "shortGeneric", + }) + .formatToParts(now) + .find((p) => p.type === "timeZoneName").value; + const pad = (n) => String(n).padStart(2, "0"); + local = `Local: ${now.toLocaleString("sv-SE", { hour12: false })} ${zone}`; + utc = + `UTC: ${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` + + `${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`; + } catch { + // Both clocks are left blank. + } + document.getElementById("clock-local").textContent = local; + document.getElementById("clock-utc").textContent = utc; +} + // --- Debug Log Renderer ------------------------------------------------------ // One line of the debug log panel, its message set as text. @@ -1509,24 +1536,6 @@ async function init() { sortAndRebuildWAN(state); }); - function updateClocks() { - const now = new Date(); - const pad = (n) => String(n).padStart(2, "0"); - const utc = - `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` + - `${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`; - const local = - now.toLocaleString("sv-SE", { hour12: false }) + - " " + - new Intl.DateTimeFormat("en-US", { timeZoneName: "shortGeneric" }) - .formatToParts(now) - .find((p) => p.type === "timeZoneName").value; - document.getElementById("clock-local").textContent = "Local: " + local; - document.getElementById("clock-utc").textContent = "UTC: " + utc; - } - updateClocks(); - 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 // more. At a steady interval they never are, as they time out at 80% of @@ -1606,6 +1615,11 @@ async function init() { window.addEventListener("resize", () => handleResize(state)); setTimeout(() => handleResize(state), CONFIG.resizeDelay); + + // The clocks start last, after the rounds: they only decorate the page, + // so nothing in them may stop the monitor from probing. + updateClocks(); + setInterval(updateClocks, 1000); } // Bootstrap only when loaded as the page: a real DOM containing the #app -- 2.54.0