1 Commits
Author SHA1 Message Date
sneak 740143bc32 Move tailwind off the deprecated module.register() (closes #32)
check / check (push) Successful in 4m31s
Frontend builds on Node 26 or newer, such as make test on a host with
Node 26, printed Node's DEP0205 warning; the build in Dockerfile runs on
Node 22, which never printed it. The trace names @tailwindcss/node,
which @tailwindcss/vite brings in at its own exact version; tailwind
4.3.1 calls module.registerHooks() where Node has it. @tailwindcss/vite
and tailwindcss move to 4.3.3, inside the ranges package.json already
allows, so only yarn.lock changes, every entry still with an integrity
hash. tailwindcss moves too so that one tailwind version is installed.

Model: opus-5-5
2026-10-04 01:14:10 +00:00
6 changed files with 14 additions and 197 deletions
+4 -5
View File
@@ -47,17 +47,15 @@ halves, so the root `make check` fails if either one is broken. We provide:
- `script/setup` — make a fresh clone ready for development: bootstrap plus the - `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run `script/frontend-test`, then `backend/script/test`, the - `script/test` — run `script/frontend-test`, then the backend's Go tests, each
backend's Go tests with the race detector and coverage under its own 30-second timeout
- `script/lint` — run eslint, then golangci-lint, both in Docker, by building - `script/lint` — run eslint, then golangci-lint, both in Docker, by building
the `frontend-lint` and `lint` stages of `Dockerfile` without the cache the `frontend-lint` and `lint` stages of `Dockerfile` without the cache
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/` - `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt - `script/fmt-check` — check formatting (read-only): prettier, then gofmt
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/frontend-test` — run the unit tests in `test/unit/` with Node's - `script/frontend-test` — run the unit tests in `test/unit/` with Node's
built-in test runner, through the `test` script in `package.json`, and if any built-in test runner, then the production build
fails, run them again listing every test, and fail; then the production build.
Each run has a 30-second timeout
- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it - `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint` runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
builds builds
@@ -269,6 +267,7 @@ properties.
## TODO ## TODO
- Add unit tests
- Add configurable host list (environment variable or config file) - Add configurable host list (environment variable or config file)
- Add latency history export (CSV/JSON) - Add latency history export (CSV/JSON)
- Add notification/alert when status changes to DEGRADED - Add notification/alert when status changes to DEGRADED
-10
View File
@@ -35,16 +35,6 @@ latest run passes.
Firefox focus-ring rule, which no longer applies to iframes (the page has Firefox focus-ring rule, which no longer applies to iframes (the page has
none, so nothing on it looks different), and in tailwind's default sans-serif none, so nothing on it looks different), and in tailwind's default sans-serif
font list, which the page does not use: `body` sets a monospace font font list, which the page does not use: `body` sets a monospace font
- 2026-10-03: the frontend's unit tests cover what the page computes (issue
#21): `humanDuration`, the latency colours of a figure and of a sparkline
either side of each boundary, a target's min, max, average and median latency
over an empty history, an all-unreachable one and a mixed one, and each of the
four health states either side of its thresholds. `package.json` has a `test`
script, so `yarn run test` and `npm run test` run them, and
`script/frontend-test` runs it: quietly, and if a test fails, again with every
test listed, and then fails. `src/main.js` now exports `humanDuration`,
`HostState`, `latencyClass` and `latencyHex` for the tests; nothing it does
changed
- 2026-10-03: the backend's logs are one stream (issue #27): fx logs its own - 2026-10-03: the backend's logs are one stream (issue #27): fx logs its own
steps of starting and stopping through the backend's logger, so off a terminal steps of starting and stopping through the backend's logger, so off a terminal
every line the backend's own logger and fx write is JSON, where fx used to every line the backend's own logger and fx write is JSON, where fx used to
+1 -2
View File
@@ -6,8 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview"
"test": "node --test test/unit/*.test.js"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
+3 -11
View File
@@ -1,22 +1,14 @@
#!/bin/sh #!/bin/sh
# script/frontend-test: run the frontend test suite: the unit tests in # script/frontend-test: run the frontend test suite: the unit tests in
# test/unit/, through the test script in package.json, then the # test/unit/ with Node's built-in test runner, then the production
# production build, which fails on broken code. The tests print a dot # build, which fails on broken code.
# each; if any fails, they run again with every test listed, and the
# script fails even if that run passes. NODE_OPTIONS chooses the
# reporter because yarn adds its arguments after the test files, where
# node would take a reporter option for one more file.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
NODE_OPTIONS=--test-reporter=dot timeout 30 yarn --silent run test || { timeout 30 node --test test/unit/*.test.js
echo "--- Rerunning with every test listed for details ---"
NODE_OPTIONS=--test-reporter=spec timeout 30 yarn --silent run test
exit 1
}
timeout 30 yarn build timeout 30 yarn build
} }
+4 -4
View File
@@ -151,7 +151,7 @@ function formatUTCTimestamp(date) {
// --- Duration Formatting ----------------------------------------------------- // --- Duration Formatting -----------------------------------------------------
export function humanDuration(seconds) { function humanDuration(seconds) {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60; const s = seconds % 60;
@@ -197,7 +197,7 @@ async function detectGateway() {
// --- App State --------------------------------------------------------------- // --- App State ---------------------------------------------------------------
export class HostState { class HostState {
constructor(host, pinned = false) { constructor(host, pinned = false) {
this.name = host.name; this.name = host.name;
this.url = host.url; this.url = host.url;
@@ -546,7 +546,7 @@ export async function measureLatency(url, signal) {
// --- Color Helpers ----------------------------------------------------------- // --- Color Helpers -----------------------------------------------------------
export function latencyHex(latency) { function latencyHex(latency) {
if (latency === null) return "#6b7280"; if (latency === null) return "#6b7280";
if (latency < 50) return "#22c55e"; if (latency < 50) return "#22c55e";
if (latency < 100) return "#84cc16"; if (latency < 100) return "#84cc16";
@@ -555,7 +555,7 @@ export function latencyHex(latency) {
return "#ef4444"; return "#ef4444";
} }
export function latencyClass(latency, status) { function latencyClass(latency, status) {
if (status === "offline" || status === "error" || latency === null) if (status === "offline" || status === "error" || latency === null)
return "text-gray-500"; return "text-gray-500";
if (latency < 50) return "text-green-500"; if (latency < 50) return "text-green-500";
+2 -165
View File
@@ -1,6 +1,5 @@
// Unit tests for src/main.js, run with Node's built-in test runner by the // Unit tests for src/main.js, run by script/frontend-test with Node's
// test script in package.json. Importing the module does not start the // built-in test runner. Importing the module does not start the page.
// page.
import { beforeEach, test } from "node:test"; import { beforeEach, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@@ -8,10 +7,6 @@ import {
AppState, AppState,
CONFIG, CONFIG,
greyOutUI, greyOutUI,
HostState,
humanDuration,
latencyClass,
latencyHex,
measureLatency, measureLatency,
tick, tick,
} from "../../src/main.js"; } from "../../src/main.js";
@@ -229,161 +224,3 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
assert.notEqual(statusText(state, host), "paused", host.name); assert.notEqual(statusText(state, host), "paused", host.name);
} }
}); });
for (const [seconds, text] of [
[0, "0s"],
[1, "1s"],
[59, "59s"],
[60, "1m"],
[61, "1m1s"],
[3599, "59m59s"],
[3600, "1h"],
[3601, "1h1s"],
[3660, "1h1m"],
[3661, "1h1m1s"],
]) {
test(`humanDuration writes ${seconds} seconds as ${text}`, () => {
assert.equal(humanDuration(seconds), text);
});
}
// The colour of a target's latency figure, as a class, and of its sparkline
// for an answer after latency ms, as the colour coding in README.md gives
// them. The rows sit either side of each boundary.
for (const [latency, figure, sparkline] of [
[0, "text-green-500", "#22c55e"],
[49, "text-green-500", "#22c55e"],
[50, "text-lime-500", "#84cc16"],
[99, "text-lime-500", "#84cc16"],
[100, "text-yellow-500", "#eab308"],
[199, "text-yellow-500", "#eab308"],
[200, "text-orange-500", "#f97316"],
[499, "text-orange-500", "#f97316"],
[500, "text-red-500", "#ef4444"],
]) {
test(`an answer after ${latency}ms has a ${figure} figure and a ${sparkline} sparkline`, () => {
assert.equal(latencyClass(latency, "online"), figure);
assert.equal(latencyHex(latency), sparkline);
});
}
test("a check that timed out or found its target unreachable has a grey figure and sparkline", () => {
assert.equal(latencyClass(null, "error"), "text-gray-500");
assert.equal(latencyClass(null, "offline"), "text-gray-500");
assert.equal(latencyHex(null), "#6b7280");
});
// A target whose checks, in turn, answered after each of latencies ms, or,
// for null, found it unreachable.
function hostAfter(latencies) {
const host = new HostState({ name: "Target", url: "https://target.test" });
for (const latency of latencies) {
host.pushSample(
Date.now(),
latency === null
? { latency: null, error: "unreachable" }
: { latency, error: null },
);
}
return host;
}
for (const { history, latencies, statistics } of [
{
history: "no checks",
latencies: [],
statistics: { min: null, max: null, average: null, median: null },
},
{
history: "only unreachable checks",
latencies: [null, null, null],
statistics: { min: null, max: null, average: null, median: null },
},
{
history: "three answers and an unreachable check",
latencies: [30, null, 10, 20],
statistics: { min: 10, max: 30, average: 20, median: 20 },
},
{
// The median of an even number of answers is the mean of the
// middle two. It and the average, 23.75, are rounded.
history: "four answers",
latencies: [10, 40, 20, 25],
statistics: { min: 10, max: 40, average: 24, median: 23 },
},
{
// Sorted as text rather than as numbers, these answers would put
// 100 in the middle. The average, 39.67, is rounded.
history: "answers with different numbers of digits",
latencies: [100, 9, 10],
statistics: { min: 9, max: 100, average: 40, median: 10 },
},
]) {
test(`a target's min, max, average and median latency over ${history}`, () => {
const host = hostAfter(latencies);
assert.deepEqual(
{
min: host.minLatency(),
max: host.maxLatency(),
average: host.averageLatency(),
median: host.medianLatency(),
},
statistics,
);
});
}
// 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.
function stateAfter({ timedOut, unreachable, answered, latency }) {
const state = new AppState([]);
const results = [
...Array(timedOut).fill({ latency: null, error: "timeout" }),
...Array(unreachable).fill({ latency: null, error: "unreachable" }),
...Array(answered).fill({ latency, error: null }),
];
results.forEach((result, i) => state.wan[i].pushSample(Date.now(), result));
return state;
}
// For each number the health is decided by, the rows put it one under, at
// and one over its threshold.
for (const { timedOut, unreachable = 0, answered, latency, health } of [
// Offline: more than 10 timed out and at most 4 answered.
{ timedOut: 9, answered: 4, latency: 30, health: "degraded" },
{ timedOut: 10, answered: 4, latency: 30, health: "degraded" },
{ timedOut: 11, answered: 4, latency: 30, health: "offline" },
{ timedOut: 11, answered: 3, latency: 30, health: "offline" },
{ timedOut: 11, answered: 5, latency: 30, health: "degraded" },
// Otherwise degraded: more than 4 timed out.
{ timedOut: 3, answered: 10, latency: 30, health: "healthy" },
{ timedOut: 4, answered: 10, latency: 30, health: "healthy" },
{ timedOut: 5, answered: 10, latency: 30, health: "degraded" },
// Otherwise slow: more than 3 answered after more than 1000ms.
{ timedOut: 0, answered: 4, latency: 999, health: "healthy" },
{ timedOut: 0, answered: 4, latency: 1000, health: "healthy" },
{ timedOut: 0, answered: 4, latency: 1001, health: "slow" },
{ timedOut: 0, answered: 2, latency: 1001, health: "healthy" },
{ timedOut: 0, answered: 3, latency: 1001, health: "healthy" },
// A target found unreachable counts as one that timed out.
{
timedOut: 5,
unreachable: 6,
answered: 4,
latency: 30,
health: "offline",
},
{
timedOut: 0,
unreachable: 5,
answered: 10,
latency: 30,
health: "degraded",
},
]) {
test(`with ${timedOut} WAN targets timed out, ${unreachable} found unreachable and ${answered} answering after ${latency}ms, the health is ${health}`, () => {
const state = stateAfter({ timedOut, unreachable, answered, latency });
assert.equal(state.healthStatus(), health);
});
}