From e6d6815ecb1a57b0124a51f2f8e268032556499b Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Fri, 2 Oct 2026 01:05:09 +0200
Subject: [PATCH 01/62] Remove Buildarch: read the architecture at run time
(closes #83)
The architecture is no longer passed in at build time. The Buildarch
variable and field are gone from main and globals, script/build no
longer stamps it in with -X, and the startup and listen log lines
report runtime.GOARCH under the key "arch". The Dockerfile comment and
backend/README.md no longer describe an architecture being stamped in.
Model: opus-5-5
---
Dockerfile | 2 +-
backend/README.md | 2 +-
backend/cmd/netwatch-server/main.go | 6 ++----
backend/internal/globals/globals.go | 12 ++++--------
backend/internal/logger/logger.go | 3 ++-
backend/internal/server/http.go | 3 ++-
backend/script/build | 4 ++--
7 files changed, 14 insertions(+), 18 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index d13e53e..1504805 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -37,7 +37,7 @@ RUN make test
# make build is a shim around backend/script/build, the one definition
# of the build command:
-# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=... -X main.Buildarch=..."
+# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=..."
# That script reads VERSION from the environment, so it is handed over
# there rather than as a make variable.
ARG VERSION=dev
diff --git a/backend/README.md b/backend/README.md
index 9914eb0..82ca05e 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -32,7 +32,7 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
`test`, `fmt` and `fmt-check`:
- `script/build` — compile the static `netwatch-server` binary with its version
- and architecture stamped in. The version is `VERSION` from the environment;
+ stamped in. The version is `VERSION` from the environment;
when that is unset or empty, it falls back to `git describe` inside a git
checkout, then to `dev`
- `script/test` — run the Go tests under a 30-second timeout
diff --git a/backend/cmd/netwatch-server/main.go b/backend/cmd/netwatch-server/main.go
index 01c77a4..7410367 100644
--- a/backend/cmd/netwatch-server/main.go
+++ b/backend/cmd/netwatch-server/main.go
@@ -19,9 +19,8 @@ import (
//nolint:gochecknoglobals // set via ldflags at build time
var (
- Appname = "netwatch-server"
- Version string
- Buildarch string
+ Appname = "netwatch-server"
+ Version string
)
func main() {
@@ -40,7 +39,6 @@ func main() {
globals.Appname = Appname
globals.Version = Version
- globals.Buildarch = Buildarch
fx.New(
fx.Provide(
diff --git a/backend/internal/globals/globals.go b/backend/internal/globals/globals.go
index 0e3807b..c6a48a2 100644
--- a/backend/internal/globals/globals.go
+++ b/backend/internal/globals/globals.go
@@ -10,22 +10,18 @@ var (
Appname string
// Version is the git version tag.
Version string
- // Buildarch is the build architecture.
- Buildarch string
)
// Globals holds build-time metadata for the application.
type Globals struct {
- Appname string
- Version string
- Buildarch string
+ Appname string
+ Version string
}
// New creates a Globals instance from package-level variables.
func New(_ fx.Lifecycle) (*Globals, error) {
return &Globals{
- Appname: Appname,
- Buildarch: Buildarch,
- Version: Version,
+ Appname: Appname,
+ Version: Version,
}, nil
}
diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go
index de6c884..fb31eeb 100644
--- a/backend/internal/logger/logger.go
+++ b/backend/internal/logger/logger.go
@@ -5,6 +5,7 @@ package logger
import (
"log/slog"
"os"
+ "runtime"
"sneak.berlin/go/netwatch/internal/globals"
@@ -95,6 +96,6 @@ func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
- "buildarch", l.params.Globals.Buildarch,
+ "arch", runtime.GOARCH,
)
}
diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go
index 46f4a67..df66092 100644
--- a/backend/internal/server/http.go
+++ b/backend/internal/server/http.go
@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
+ "runtime"
"strconv"
"time"
@@ -53,7 +54,7 @@ func (s *Server) listenAndServe() {
s.log.Info("http begin listen",
"listenaddr", s.httpServer.Addr,
"version", s.params.Globals.Version,
- "buildarch", s.params.Globals.Buildarch,
+ "arch", runtime.GOARCH,
)
err := s.httpServer.ListenAndServe()
diff --git a/backend/script/build b/backend/script/build
index 40d4d01..4f30155 100755
--- a/backend/script/build
+++ b/backend/script/build
@@ -1,6 +1,6 @@
#!/bin/sh
# script/build: compile the static netwatch-server binary into the
-# backend project root, with its version and architecture stamped in.
+# backend project root, with its version stamped in.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -14,7 +14,7 @@ main() {
version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}"
CGO_ENABLED=0 go build -trimpath \
- -ldflags "-s -w -X main.Version=$version -X main.Buildarch=$(uname -m)" \
+ -ldflags "-s -w -X main.Version=$version" \
-o netwatch-server ./cmd/netwatch-server/
}
--
2.54.0
From 4ce0814b1481bfdb67878deb97e9f1f73a09b4d5 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Fri, 2 Oct 2026 04:26:40 +0200
Subject: [PATCH 02/62] Stamp the git tag or short commit in a plain docker
build (closes #86)
The builder stage now has git and takes the version from the VERSION
build argument when one is given, otherwise from `git describe --tags
--always` of the repo's .git, copied to /git so go build does not see
it. A version that still comes out empty, dev or unknown fails the
build. ARG VERSION has no default. .dockerignore keeps .git/config out
of the build context, and the comments in script/docker and
script/cibuild no longer say .git is excluded.
Model: opus-5-5
---
.dockerignore | 3 +++
Dockerfile | 19 ++++++++++++++++---
script/cibuild | 5 ++---
script/docker | 5 ++---
4 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/.dockerignore b/.dockerignore
index 416281f..033ab7b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,3 +4,6 @@ tmp
.DS_Store
*.log
.claude
+
+# .git is sent so the build can stamp the version, without its config.
+.git/config
diff --git a/Dockerfile b/Dockerfile
index 1504805..4203274 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -20,7 +20,7 @@ RUN make lint
# golang:1.25-alpine (2026-02-27)
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
-RUN apk add --no-cache make
+RUN apk add --no-cache git make
WORKDIR /src
@@ -40,8 +40,21 @@ RUN make test
# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=..."
# That script reads VERSION from the environment, so it is handed over
# there rather than as a make variable.
-ARG VERSION=dev
-RUN VERSION="${VERSION}" make build
+#
+# The version is the VERSION build argument when one is given, otherwise
+# `git describe --tags --always` of the repo's .git: the tag on a tagged
+# commit, tag-N-gHASH on a commit after one, the short commit when no
+# tag is reachable. A version that still comes out empty, dev or unknown
+# fails the build. .git goes to /git, not /src/.git, where go build would
+# find it and record VCS details of a work tree holding only backend/.
+COPY .git /git
+ARG VERSION
+RUN version="${VERSION:-$(git --git-dir=/git describe --tags --always)}"; \
+ case "$version" in ""|dev|unknown) \
+ echo "version is '$version' although .git is present" >&2; \
+ exit 1 ;; \
+ esac; \
+ VERSION="$version" make build
# Frontend stage
# node:22-alpine as of 2026-02-22
diff --git a/script/cibuild b/script/cibuild
index 688299f..d8d3200 100755
--- a/script/cibuild
+++ b/script/cibuild
@@ -16,9 +16,8 @@ main() {
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
- # empty constant. VERSION is computed here because .dockerignore
- # excludes .git, so `git describe` in a build stage yields an empty
- # version without failing.
+ # empty constant. The VERSION build argument takes precedence over
+ # the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
diff --git a/script/docker b/script/docker
index c4688e8..07b626c 100755
--- a/script/docker
+++ b/script/docker
@@ -12,9 +12,8 @@ main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
- # empty constant. VERSION is computed here because .dockerignore
- # excludes .git, so `git describe` in a build stage yields an empty
- # version without failing.
+ # empty constant. The VERSION build argument takes precedence over
+ # the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--
2.54.0
From 0ab6418e3dada7c3e210aeb27ff5ea001396be53 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sat, 3 Oct 2026 15:36:47 +0200
Subject: [PATCH 03/62] Target check timeout is 80% of the refresh interval
(closes #78)
Each target check now times out after 80% of the refresh interval, 24
seconds at 30 seconds, where it was capped at 3 seconds, so slow, far
targets are recorded with their real time. Rounds never overlap: a
round gives up the last round's checks if they are still waiting, which
happens only when a round starts early, after an interval change or
when the recovery probe finds a target answering. The probe still
checks every half second, giving up its previous checks, so they do not
pile up.
The frontend has its first unit tests, run by script/frontend-test with
Node's built-in test runner on a mocked clock. index.html now links
src/styles.css, which src/main.js imported, since Node cannot import
CSS.
Model: opus-5-5
---
Dockerfile | 5 ++-
README.md | 14 +++++--
TODO.md | 8 ++++
index.html | 3 ++
script/frontend-test | 7 ++--
src/main.js | 83 +++++++++++++++++++++++++++---------------
test/unit/main.test.js | 66 +++++++++++++++++++++++++++++++++
7 files changed, 148 insertions(+), 38 deletions(-)
create mode 100644 test/unit/main.test.js
diff --git a/Dockerfile b/Dockerfile
index 4203274..612d8a7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -65,8 +65,9 @@ RUN yarn install --frozen-lockfile
RUN apk add --no-cache git make
COPY . .
# make frontend-check is the frontend half of make check (test + lint +
-# fmt-check); its test step is the production yarn build, so this both
-# produces dist/ and gates the image on lint/fmt-check/test regressions.
+# fmt-check); its test step runs the unit tests, then the production
+# yarn build, so this both produces dist/ and gates the image on
+# lint/fmt-check/test regressions.
# This node stage has neither Go nor Docker; the lint and builder stages
# above gate the backend half.
RUN make frontend-check
diff --git a/README.md b/README.md
index acbb62d..0db2a61 100644
--- a/README.md
+++ b/README.md
@@ -52,8 +52,8 @@ halves, so the root `make check` fails if either one is broken. We provide:
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
- `script/check` — run test, lint, and fmt-check
-- `script/frontend-test` — run the production build as the frontend's test (no
- unit tests yet)
+- `script/frontend-test` — run the unit tests in `test/unit/` with Node's
+ built-in test runner, then the production build
- `script/frontend-lint` — run prettier in check mode
- `script/frontend-fmt` — format everything prettier understands (writes)
- `script/frontend-fmt-check` — check prettier formatting (read-only)
@@ -136,8 +136,14 @@ Local hosts are tracked separately from WAN stats.
### Latency measurement
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with
-`performance.now()`. 1-second timeout; anything over 1000ms is clamped to
-unreachable. IPv4 only.
+`performance.now()`. Each check times out after 80% of the refresh interval (24
+seconds at 30 seconds) and is then recorded as a timeout, so a round's checks
+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
+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
+last round's checks if they are still waiting, and that round records nothing,
+so rounds never overlap. IPv4 only.
### Color coding
diff --git a/TODO.md b/TODO.md
index 8b17dd2..a5c802e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,14 @@ latest run passes.
# Completed Steps
+- 2026-10-03: each target check times out after 80% of the refresh interval
+ (issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
+ round started early, after an interval change or when the recovery probe finds
+ a target answering, gives up the last round's checks if they are still
+ waiting, so rounds never overlap; the recovery probe gives up its own checks
+ after half a second. The frontend has its first unit tests, run by
+ `script/frontend-test` with Node's built-in test runner; for them,
+ `index.html` now links `src/styles.css`, which `src/main.js` used to import
- 2026-09-29: the container sets up its own data directory (issue #75):
`bin/entrypoint.sh`, still as root, creates `DATA_DIR` if missing and gives it
and `/data` to the `netwatch` user with mode 750 before starting the backend
diff --git a/index.html b/index.html
index 3af620d..ddb6af0 100644
--- a/index.html
+++ b/index.html
@@ -9,6 +9,9 @@
type="image/svg+xml"
href="data:image/svg+xml,"
/>
+
+
diff --git a/script/frontend-test b/script/frontend-test
index 4c74c65..c923f70 100755
--- a/script/frontend-test
+++ b/script/frontend-test
@@ -1,13 +1,14 @@
#!/bin/sh
-# script/frontend-test: run the frontend test suite. The frontend has no
-# unit tests; the production build serves as the test (fails on broken
-# code).
+# script/frontend-test: run the frontend test suite: the unit tests in
+# test/unit/ with Node's built-in test runner, then the production
+# build, which fails on broken code.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
+ timeout 30 node --test test/unit/*.test.js
timeout 30 yarn build
}
diff --git a/src/main.js b/src/main.js
index 526a544..82f1dbd 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,14 +1,14 @@
-import "./styles.css";
-
// --- Configuration -----------------------------------------------------------
-// Timing, axis labels, and display constants. Latency above maxLatency is
-// clamped to "unreachable". The sparkline Y-axis is capped at
+// Timing, axis labels, and display constants. A target check times out
+// after requestTimeout, 80% of updateInterval, so a round's checks have
+// all finished before the next round is due; latency above maxLatency is
+// recorded as a timeout. The sparkline Y-axis is capped at
// graphMaxLatency — values above it pin to the top of the chart but still
// display their real value in the latency figure. The history buffer holds
// maxHistoryPoints samples (historyDuration / updateInterval).
// reportInterval is how often collected samples are POSTed to the backend.
-const CONFIG = {
+export const CONFIG = {
updateInterval: 3000,
maxHistoryPoints: 100,
reportInterval: 60000,
@@ -16,7 +16,7 @@ const CONFIG = {
return (this.maxHistoryPoints * this.updateInterval) / 1000;
},
get requestTimeout() {
- return Math.min(this.updateInterval - 100, 3000);
+ return this.updateInterval * 0.8;
},
get maxLatency() {
return this.requestTimeout;
@@ -503,12 +503,16 @@ class Reporter {
// --- Latency Measurement -----------------------------------------------------
-async function measureLatency(url) {
+// Checks one target. The check times out after CONFIG.requestTimeout; the
+// caller can give it up sooner through the optional signal, which also ends
+// it as a timeout.
+export async function measureLatency(url, signal) {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
CONFIG.requestTimeout,
);
+ signal?.addEventListener("abort", () => controller.abort());
const targetUrl = new URL(url);
targetUrl.searchParams.set("_cb", Date.now().toString());
@@ -1101,7 +1105,7 @@ function sortAndRebuildWAN(state) {
// --- Main Loop ---------------------------------------------------------------
-async function tick(state, onOffline) {
+async function tick(state, signal, onOffline) {
const ts = Date.now();
if (state.paused) {
@@ -1123,11 +1127,12 @@ async function tick(state, onOffline) {
log.debug(`Tick #${state.tickCount + 1} started`);
const results = await Promise.all(
- state.allHosts.map((h) => measureLatency(h.url)),
+ state.allHosts.map((h) => measureLatency(h.url, signal)),
);
- // User may have paused while awaiting results — discard them
- if (state.paused) return;
+ // User may have paused, or the next round may have given up this
+ // one's checks, while awaiting results — discard them
+ if (state.paused || signal.aborted) return;
state.tickCount++;
@@ -1168,9 +1173,10 @@ async function tick(state, onOffline) {
// --- Recovery Probe ----------------------------------------------------------
-// When offline, rapidly poll 4 random WAN hosts every 500ms. As soon as any
-// responds, stop probing and fire a normal tick to refresh all hosts.
-function startRecoveryProbe(state, triggerTick) {
+// When offline, check 4 random WAN hosts every 500ms, giving up the checks
+// started 500ms before, so at most 4 are ever waiting. As soon as one
+// answers, stop probing and start a new round at once.
+function startRecoveryProbe(state, startRounds) {
if (state._recoveryProbeId) return; // already running
const candidates = [...state.wan];
for (let i = candidates.length - 1; i > 0; i--) {
@@ -1181,15 +1187,18 @@ function startRecoveryProbe(state, triggerTick) {
log.notice(
`Recovery probe started (${canaries.map((h) => h.name).join(", ")})`,
);
- state._recoveryProbeId = setInterval(async () => {
+ state._recoveryProbeId = setInterval(() => {
if (state.paused) return;
- const results = await Promise.all(
- canaries.map((h) => measureLatency(h.url)),
- );
- if (results.some((r) => r.error === null)) {
- log.notice("Recovery probe: connectivity detected");
- stopRecoveryProbe(state);
- triggerTick();
+ state._recoveryProbeChecks?.abort();
+ const checks = new AbortController();
+ state._recoveryProbeChecks = checks;
+ for (const host of canaries) {
+ measureLatency(host.url, checks.signal).then((r) => {
+ if (r.error !== null || checks.signal.aborted) return;
+ log.notice("Recovery probe: connectivity detected");
+ stopRecoveryProbe(state);
+ startRounds();
+ });
}
}, 500);
}
@@ -1198,6 +1207,7 @@ function stopRecoveryProbe(state) {
if (state._recoveryProbeId) {
clearInterval(state._recoveryProbeId);
state._recoveryProbeId = null;
+ state._recoveryProbeChecks?.abort();
}
}
@@ -1374,18 +1384,34 @@ async function init() {
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.
+ // At a steady interval they never are, as they time out at 80% of it;
+ // they can be when a round starts early, after an interval change or
+ // when the recovery probe finds a target answering.
+ let roundChecks = new AbortController();
function doTick() {
- tick(state, () => startRecoveryProbe(state, doTick));
+ roundChecks.abort();
+ roundChecks = new AbortController();
+ tick(state, roundChecks.signal, () =>
+ startRecoveryProbe(state, startRounds),
+ );
}
- doTick();
- let tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
+ // Starts a round now and then one every CONFIG.updateInterval.
+ let tickIntervalId;
+ function startRounds() {
+ clearInterval(tickIntervalId);
+ doTick();
+ tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
+ }
+
+ startRounds();
document
.getElementById("interval-select")
.addEventListener("change", (e) => {
const newInterval = parseInt(e.target.value, 10);
- clearInterval(tickIntervalId);
CONFIG.updateInterval = newInterval;
log.notice(
`Interval changed to ${humanDuration(newInterval / 1000)}, history reset`,
@@ -1434,8 +1460,7 @@ async function init() {
// Start immediately with new interval
stopRecoveryProbe(state);
- doTick();
- tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
+ startRounds();
});
window.addEventListener("resize", () => handleResize(state));
@@ -1444,7 +1469,7 @@ async function init() {
// Bootstrap only when loaded as the page: a real DOM containing the #app
// mount point this module renders into. Importing the module in a unit test
-// (which has no #app) runs nothing, so buildReport can be tested in isolation.
+// (which has no #app) runs nothing, so its exports can be tested in isolation.
if (typeof document !== "undefined" && document.getElementById("app")) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
diff --git a/test/unit/main.test.js b/test/unit/main.test.js
new file mode 100644
index 0000000..c49ef63
--- /dev/null
+++ b/test/unit/main.test.js
@@ -0,0 +1,66 @@
+// 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.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { CONFIG, measureLatency } from "../../src/main.js";
+
+// measureLatency writes timeouts to the debug log, which looks for its
+// panel in the page. There is no page here.
+globalThis.document = { getElementById: () => null };
+
+// 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
+// milliseconds of that clock, or never when answerAfter is Infinity. Both
+// are restored when the test ends.
+function mockTarget(t, answerAfter) {
+ t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
+ t.mock.method(performance, "now", () => Date.now());
+ t.mock.method(
+ globalThis,
+ "fetch",
+ (url, { signal }) =>
+ new Promise((resolve, reject) => {
+ if (answerAfter !== Infinity) setTimeout(resolve, answerAfter);
+ signal.addEventListener("abort", () => reject(signal.reason));
+ }),
+ );
+}
+
+// The result of check if it has ended, otherwise "still waiting".
+function settled(check) {
+ return Promise.race([
+ check,
+ new Promise((resolve) => setImmediate(resolve, "still waiting")),
+ ]);
+}
+
+for (const interval of [10000, 30000]) {
+ const timeout = interval * 0.8;
+ // Over 3 seconds, which the timeout used to be capped at.
+ const slowAnswer = timeout - 1000;
+
+ test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => {
+ CONFIG.updateInterval = interval;
+ mockTarget(t, slowAnswer);
+ const check = measureLatency("https://target.test");
+ t.mock.timers.tick(slowAnswer);
+ assert.deepEqual(await settled(check), {
+ latency: slowAnswer,
+ error: null,
+ });
+ });
+
+ test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => {
+ CONFIG.updateInterval = interval;
+ mockTarget(t, Infinity);
+ const check = measureLatency("https://target.test");
+ t.mock.timers.tick(timeout - 1);
+ assert.equal(await settled(check), "still waiting");
+ t.mock.timers.tick(1);
+ assert.deepEqual(await settled(check), {
+ latency: null,
+ error: "timeout",
+ });
+ });
+}
--
2.54.0
From aa35625d47490f7d3481672dc48fe1d0250a0e71 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sat, 3 Oct 2026 16:26:17 +0200
Subject: [PATCH 04/62] Go tests run with -race and -cover under Go's own
timeout (closes #88)
backend/script/test runs go test -timeout 30s -race -cover and, if
that fails, runs it again with -v and fails. The root script/test drops
its one 30-second timeout around both halves: from a cold Go build
cache, compiling the tests with -race used it all up. Each half keeps
its own limit. The race detector needs a C compiler: the Dockerfile
builder stage gains gcc and musl-dev, and script/bootstrap installs gcc,
with the C library headers on apt and apk, when gcc is missing; make
build still sets CGO_ENABLED=0. New tests: the health check's answer, a
valid report's answer, a report file's exact lines, and the flush at the
10 MiB threshold. The handlers TestImport stub is gone.
Model: opus-5-5
---
Dockerfile | 5 +-
README.md | 12 +-
TODO.md | 11 ++
backend/README.md | 5 +-
backend/internal/handlers/handlers_test.go | 13 --
backend/internal/handlers/healthcheck_test.go | 120 ++++++++++++++++++
backend/internal/handlers/report_test.go | 57 +++++++++
backend/internal/reportbuf/export_test.go | 4 +
backend/internal/reportbuf/reportbuf_test.go | 105 +++++++++++++--
backend/script/test | 12 +-
script/bootstrap | 13 +-
script/test | 9 +-
12 files changed, 328 insertions(+), 38 deletions(-)
delete mode 100644 backend/internal/handlers/handlers_test.go
create mode 100644 backend/internal/handlers/healthcheck_test.go
diff --git a/Dockerfile b/Dockerfile
index 612d8a7..30ddf6b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -20,7 +20,10 @@ RUN make lint
# golang:1.25-alpine (2026-02-27)
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
-RUN apk add --no-cache git make
+# gcc and musl-dev are for make test: its race detector needs cgo, which
+# Go turns on by itself once a C compiler is present. make build still
+# sets CGO_ENABLED=0, so the binary stays static.
+RUN apk add --no-cache gcc git make musl-dev
WORKDIR /src
diff --git a/README.md b/README.md
index 0db2a61..e604338 100644
--- a/README.md
+++ b/README.md
@@ -39,14 +39,16 @@ halves, so the root `make check` fails if either one is broken. We provide:
- `script/bootstrap` — install all dependencies (the pinned node via nvm unless
one new enough for the frontend's dependencies is installed, yarn via
corepack, `yarn install --frozen-lockfile`, the pinned Go unless one at least
- as new as `backend/go.mod` asks for is installed, and the Go modules), linking
- what it installs itself into `~/.local/bin`, which has to be on `PATH`. It
- installs no Go linter and not Docker: `make lint` runs the linter in Docker
+ as new as `backend/go.mod` asks for is installed, the Go modules, and gcc with
+ the C library headers unless gcc is installed, for the race detector in
+ `make test`), linking what it installs itself into `~/.local/bin`, which has
+ to be on `PATH`. It installs no Go linter and not Docker: `make lint` runs the
+ linter in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag)
-- `script/test` — run `script/frontend-test`, then the backend's Go tests, both
- within one 30-second timeout
+- `script/test` — run `script/frontend-test`, then the backend's Go tests, each
+ under its own 30-second timeout
- `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by
building the lint stage of `Dockerfile` without the cache
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
diff --git a/TODO.md b/TODO.md
index a5c802e..536f37f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,17 @@ latest run passes.
# Completed Steps
+- 2026-10-03: the Go tests run with the race detector and coverage (issue #88):
+ `backend/script/test` runs `go test -timeout 30s -race -cover ./...` and, if
+ that fails, runs it again with `-v` and fails. Go's `-timeout` bounds the
+ tests, not their compile; the root `script/test` no longer puts one 30-second
+ timeout around both halves, which a cold Go build cache could use up on
+ compiling alone. The race detector needs a C compiler: the builder stage of
+ `Dockerfile` has gcc and musl-dev, and `script/bootstrap` installs gcc, with
+ the C library headers on apt and apk, when gcc is missing; the binary is still
+ built with `CGO_ENABLED=0`. New tests cover the health check's answer, a valid
+ report's answer, a report file's exact contents, and the flush when the buffer
+ reaches 10 MiB; the handlers' `TestImport` stub is gone
- 2026-10-03: each target check times out after 80% of the refresh interval
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
round started early, after an interval change or when the recovery probe finds
diff --git a/backend/README.md b/backend/README.md
index 82ca05e..0161cc4 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -35,7 +35,10 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
stamped in. The version is `VERSION` from the environment;
when that is unset or empty, it falls back to `git describe` inside a git
checkout, then to `dev`
-- `script/test` — run the Go tests under a 30-second timeout
+- `script/test` — run the Go tests with the race detector and coverage. Go's
+ `-timeout 30s` bounds the tests, not their compile. If they fail, they run
+ again with `-v` for the details, and the script fails. The race detector needs
+ a C compiler
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run
golangci-lint. It runs inside the golangci-lint image of the lint stage of
the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
diff --git a/backend/internal/handlers/handlers_test.go b/backend/internal/handlers/handlers_test.go
deleted file mode 100644
index 5128215..0000000
--- a/backend/internal/handlers/handlers_test.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package handlers_test
-
-import (
- "testing"
-
- _ "sneak.berlin/go/netwatch/internal/handlers"
-)
-
-func TestImport(t *testing.T) {
- t.Parallel()
- // Compilation check — verifies the package parses
- // and all imports resolve.
-}
diff --git a/backend/internal/handlers/healthcheck_test.go b/backend/internal/handlers/healthcheck_test.go
new file mode 100644
index 0000000..dfbfa31
--- /dev/null
+++ b/backend/internal/handlers/healthcheck_test.go
@@ -0,0 +1,120 @@
+package handlers_test
+
+import (
+ "encoding/json"
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/netwatch/internal/globals"
+ "sneak.berlin/go/netwatch/internal/handlers"
+ "sneak.berlin/go/netwatch/internal/healthcheck"
+ "sneak.berlin/go/netwatch/internal/logger"
+
+ "go.uber.org/fx/fxtest"
+)
+
+// newStartedHandlers builds Handlers with a real health check for the
+// server named in g, and starts them, which records the time the
+// uptime counts from.
+func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
+ t.Helper()
+
+ lc := fxtest.NewLifecycle(t)
+
+ log, err := logger.New(lc, logger.Params{Globals: g})
+ if err != nil {
+ t.Fatalf("logger: %v", err)
+ }
+
+ hc, err := healthcheck.New(lc,
+ healthcheck.Params{Globals: g, Logger: log})
+ if err != nil {
+ t.Fatalf("health check: %v", err)
+ }
+
+ h, err := handlers.New(lc,
+ handlers.Params{Globals: g, Healthcheck: hc, Logger: log})
+ if err != nil {
+ t.Fatalf("handlers: %v", err)
+ }
+
+ lc.RequireStart()
+ t.Cleanup(lc.RequireStop)
+
+ return h
+}
+
+// TestHandleHealthCheck checks the health check's answer: 200, a JSON
+// content type, and a JSON object with exactly the fields of
+// healthcheck.Response, carrying this server's name and version and
+// an uptime counted from its start.
+func TestHandleHealthCheck(t *testing.T) {
+ t.Parallel()
+
+ g := &globals.Globals{Appname: "netwatch-server", Version: "v1.2.3"}
+ h := newStartedHandlers(t, g)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(),
+ http.MethodGet, "/.well-known/healthcheck", http.NoBody)
+
+ h.HandleHealthCheck().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ contentType := rec.Header().Get("Content-Type")
+ if contentType != "application/json; charset=utf-8" {
+ t.Errorf("Content-Type = %q, want %q",
+ contentType, "application/json; charset=utf-8")
+ }
+
+ var body map[string]any
+
+ err := json.Unmarshal(rec.Body.Bytes(), &body)
+ if err != nil {
+ t.Fatalf("body not a JSON object: %v (%q)", err, rec.Body.String())
+ }
+
+ fields := []string{
+ "appname", "now", "status", "uptimeHuman", "uptimeSeconds", "version",
+ }
+ if got := slices.Sorted(maps.Keys(body)); !slices.Equal(got, fields) {
+ t.Fatalf("fields = %v, want %v", got, fields)
+ }
+
+ for field, want := range map[string]string{
+ "appname": g.Appname, "status": "ok", "version": g.Version,
+ } {
+ if body[field] != want {
+ t.Errorf("%s = %v, want %q", field, body[field], want)
+ }
+ }
+
+ now, _ := body["now"].(string)
+
+ at, err := time.Parse(time.RFC3339Nano, now)
+ if err != nil || time.Since(at).Abs() > time.Minute {
+ t.Errorf("now = %q, want the current time in RFC 3339 (%v)", now, err)
+ }
+
+ // Started just now, so the uptime is well under a minute.
+ human, _ := body["uptimeHuman"].(string)
+
+ uptime, err := time.ParseDuration(human)
+ if err != nil || uptime > time.Minute {
+ t.Errorf("uptimeHuman = %q, want a duration under a minute (%v)",
+ human, err)
+ }
+
+ seconds, ok := body["uptimeSeconds"].(float64)
+ if !ok || seconds < 0 || seconds > time.Minute.Seconds() {
+ t.Errorf("uptimeSeconds = %v, want a number of seconds under a minute",
+ body["uptimeSeconds"])
+ }
+}
diff --git a/backend/internal/handlers/report_test.go b/backend/internal/handlers/report_test.go
index cd94b5c..c906b52 100644
--- a/backend/internal/handlers/report_test.go
+++ b/backend/internal/handlers/report_test.go
@@ -46,6 +46,63 @@ func decodeStatus(t *testing.T, body []byte) string {
return resp.Status
}
+// TestHandleReportAcceptsValidReports checks the answer to a valid
+// report, one with no hosts and one shaped as the frontend sends them:
+// 200 and {"status":"ok"} as JSON.
+func TestHandleReportAcceptsValidReports(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ body string
+ }{
+ {
+ name: "no hosts",
+ body: `{"clientId":"c1","geo":null,"hosts":[],` +
+ `"timestamp":"2026-10-03T12:00:00.000Z"}`,
+ },
+ {
+ name: "a host with a latency and an error sample",
+ body: `{"clientId":"c1","geo":null,"hosts":[{` +
+ `"name":"Example","url":"https://example.com/",` +
+ `"status":"error","history":[` +
+ `{"t":1790000000000,"latency":42,"error":null},` +
+ `{"t":1790000003000,"latency":null,"error":"timeout"}]}],` +
+ `"timestamp":"2026-10-03T12:00:00.000Z"}`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ h := newTestHandlers(stubAppender{}, io.Discard)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(),
+ http.MethodPost, "/api/v1/reports",
+ strings.NewReader(tt.body),
+ )
+
+ h.HandleReport().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ contentType := rec.Header().Get("Content-Type")
+ if contentType != "application/json; charset=utf-8" {
+ t.Errorf("Content-Type = %q, want %q",
+ contentType, "application/json; charset=utf-8")
+ }
+
+ if got := rec.Body.String(); got != "{\"status\":\"ok\"}\n" {
+ t.Errorf("body = %q, want %q", got, "{\"status\":\"ok\"}\n")
+ }
+ })
+ }
+}
+
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
t.Parallel()
diff --git a/backend/internal/reportbuf/export_test.go b/backend/internal/reportbuf/export_test.go
index efe8133..845feff 100644
--- a/backend/internal/reportbuf/export_test.go
+++ b/backend/internal/reportbuf/export_test.go
@@ -2,6 +2,10 @@ package reportbuf
import "time"
+// FlushSizeThreshold exposes the buffer size at which Append starts
+// writing a report file to the external tests.
+const FlushSizeThreshold = flushSizeThreshold
+
// Flush writes the buffered reports to a file now, as the periodic
// flush does, so tests need not wait a minute for it.
func (b *Buffer) Flush() error {
diff --git a/backend/internal/reportbuf/reportbuf_test.go b/backend/internal/reportbuf/reportbuf_test.go
index 0b56b7e..f9b020e 100644
--- a/backend/internal/reportbuf/reportbuf_test.go
+++ b/backend/internal/reportbuf/reportbuf_test.go
@@ -334,7 +334,11 @@ func TestTwoFlushesInOneMillisecond(t *testing.T) {
}
}
- files := readReportFiles(t, dir)
+ files, err := readReportFiles(dir)
+ if err != nil {
+ t.Fatalf("read report files: %v", err)
+ }
+
if len(files) != flushes {
t.Fatalf("%d report files after %d flushes", len(files), flushes)
}
@@ -347,6 +351,88 @@ func TestTwoFlushesInOneMillisecond(t *testing.T) {
}
}
+// TestReportFileHoldsTheLinesAppended flushes three reports and reads
+// their file back: it must decompress to exactly their JSON lines, in
+// the order they were appended.
+func TestReportFileHoldsTheLinesAppended(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DATA_DIR", dir)
+
+ buf := startBuffer(t)
+
+ for _, id := range []int{1, 2, 3} {
+ err := buf.Append(map[string]int{"id": id})
+ if err != nil {
+ t.Fatalf("append report %d: %v", id, err)
+ }
+ }
+
+ err := buf.Flush()
+ if err != nil {
+ t.Fatalf("flush: %v", err)
+ }
+
+ files, err := readReportFiles(dir)
+ if err != nil {
+ t.Fatalf("read report files: %v", err)
+ }
+
+ want := `{"id":1}` + "\n" + `{"id":2}` + "\n" + `{"id":3}` + "\n"
+ if len(files) != 1 || files[0] != want {
+ t.Fatalf("report files = %q, want one holding %q", files, want)
+ }
+}
+
+// TestFlushAtSizeThreshold appends reports until the buffer holds
+// FlushSizeThreshold bytes. The append that gets it there must write
+// them all to one report file, with no call to Flush and the periodic
+// flush a minute away, and no earlier append may write one.
+func TestFlushAtSizeThreshold(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DATA_DIR", dir)
+
+ buf := startBuffer(t)
+
+ // Large reports, so the threshold takes a few hundred appends.
+ pad := strings.Repeat("a", 64<<10)
+
+ var appended strings.Builder
+
+ for id := 0; appended.Len() < reportbuf.FlushSizeThreshold; id++ {
+ report := map[string]any{"id": id, "pad": pad}
+
+ err := buf.Append(report)
+ if err != nil {
+ t.Fatalf("append report %d: %v", id, err)
+ }
+
+ line, err := json.Marshal(report)
+ if err != nil {
+ t.Fatalf("marshal report %d: %v", id, err)
+ }
+
+ appended.Write(line)
+ appended.WriteByte('\n')
+ }
+
+ // Append writes the file in the background, so wait for it.
+ deadline := time.Now().Add(10 * time.Second)
+
+ for {
+ files, err := readReportFiles(dir)
+ if err == nil && len(files) == 1 && files[0] == appended.String() {
+ return
+ }
+
+ if time.Now().After(deadline) {
+ t.Fatalf("%d report files (error: %v), want one holding the "+
+ "%d bytes appended", len(files), err, appended.Len())
+ }
+
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
// reportFilesBytes returns the total size of the report files in dir.
func reportFilesBytes(t *testing.T, dir string) int64 {
t.Helper()
@@ -371,20 +457,19 @@ func reportFilesBytes(t *testing.T, dir string) int64 {
}
// readReportFiles returns the decompressed contents of each report
-// file in dir.
-func readReportFiles(t *testing.T, dir string) []string {
- t.Helper()
-
+// file in dir. A file still being written does not decompress, so it
+// gives an error.
+func readReportFiles(dir string) ([]string, error) {
files := os.DirFS(dir)
names, err := fs.Glob(files, "reports-*.jsonl.zst")
if err != nil {
- t.Fatalf("list report files: %v", err)
+ return nil, fmt.Errorf("list report files: %w", err)
}
dec, err := zstd.NewReader(nil)
if err != nil {
- t.Fatalf("create zstd decoder: %v", err)
+ return nil, fmt.Errorf("create zstd decoder: %w", err)
}
defer dec.Close()
@@ -393,18 +478,18 @@ func readReportFiles(t *testing.T, dir string) []string {
for _, name := range names {
compressed, readErr := fs.ReadFile(files, name)
if readErr != nil {
- t.Fatalf("read %s: %v", name, readErr)
+ return nil, fmt.Errorf("read %s: %w", name, readErr)
}
data, decErr := dec.DecodeAll(compressed, nil)
if decErr != nil {
- t.Fatalf("decompress %s: %v", name, decErr)
+ return nil, fmt.Errorf("decompress %s: %w", name, decErr)
}
contents = append(contents, string(data))
}
- return contents
+ return contents, nil
}
func writeBytes(t *testing.T, path string, n int) {
diff --git a/backend/script/test b/backend/script/test
index cbfc889..a4063e3 100755
--- a/backend/script/test
+++ b/backend/script/test
@@ -1,12 +1,20 @@
#!/bin/sh
-# script/test: run the backend test suite.
+# script/test: run the backend test suite with the race detector and
+# coverage. Go's own -timeout bounds the tests and not their compile,
+# so a cold build cache cannot fail it. The race detector needs cgo,
+# and so a C compiler. If the tests fail, they run again with -v for
+# the details, and the script fails even if that run passes.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
- timeout 30 go test ./...
+ go test -timeout 30s -race -cover ./... || {
+ echo "--- Rerunning with -v for details ---"
+ go test -timeout 30s -race -v ./...
+ exit 1
+ }
}
main "$@"
diff --git a/script/bootstrap b/script/bootstrap
index d807ee3..abe1d50 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -64,7 +64,8 @@ detect_pkgmgr() {
fi
}
-# pkg_install
+# pkg_install : the apt
+# and apk arguments may each list several packages, separated by spaces.
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
@@ -74,10 +75,10 @@ pkg_install() {
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
- $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
+ $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y $2
;;
brew) brew install "$3" ;;
- apk) apk add --no-cache "$4" ;;
+ apk) apk add --no-cache $4 ;;
esac
}
@@ -253,6 +254,12 @@ main() {
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
+ # The race detector in make test needs cgo, which Go turns on only
+ # when it finds its C compiler, gcc on Linux. apt and apk ship the C
+ # library headers apart from gcc.
+ if missing gcc; then
+ pkg_install gcc "gcc libc6-dev" gcc "gcc musl-dev"
+ fi
ensure_node
ensure_yarn
diff --git a/script/test b/script/test
index f0ad7e8..d48d3ca 100755
--- a/script/test
+++ b/script/test
@@ -1,14 +1,17 @@
#!/bin/sh
# script/test: run the test suite for the whole repo: the frontend at
-# the repo root, then the Go backend in backend/. Both halves together
-# get 30 seconds; each also keeps its own limit for the Dockerfiles.
+# the repo root, then the Go backend in backend/. Each half has its own
+# 30-second limit, and there is none around both: from a cold Go build
+# cache, compiling the backend's tests with the race detector can take
+# 30 seconds on its own, and Go's -timeout leaves the compile out.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
- timeout 30 sh -c 'script/frontend-test && backend/script/test'
+ script/frontend-test
+ backend/script/test
}
main "$@"
--
2.54.0
From 9e4d3fdb54a1aa6005c7329c7f1054283cc02cca Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sat, 3 Oct 2026 17:14:25 +0200
Subject: [PATCH 05/62] Lint's .golangci.yml check says which fix applies
(closes #34)
backend/script/lint compared .golangci.yml with its pinned sha256 and,
on any failure, said to restore the file from sneak/prompts. Once the
org standard has legitimately changed, that advice loops: the copied
file is right and GOLANGCI_CONFIG_SHA256 is stale. On a mismatch the
script now says to compare the file with the org standard, restore it
if they differ, and update GOLANGCI_CONFIG_SHA256 if they are the same;
it still prints both hashes. A missing .golangci.yml, and a sha256sum
that is missing or prints no hash, get their own messages instead of
being reported as a mismatch. Each failure still exits 1. .golangci.yml
is unchanged.
Model: opus-5-5
---
TODO.md | 8 ++++++++
backend/script/lint | 22 ++++++++++++++++++++--
2 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/TODO.md b/TODO.md
index 536f37f..b494400 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,14 @@ latest run passes.
# Completed Steps
+- 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
+ file with the org standard: if they differ, restore the org standard; if they
+ are the same, the org standard changed, so update `GOLANGCI_CONFIG_SHA256` in
+ that script. It used to say only to restore the file, which loops once the org
+ standard itself has moved. A missing `.golangci.yml`, and a `sha256sum` that
+ is missing or prints no hash, each get their own message instead of being
+ reported as a mismatch; every one still fails the lint
- 2026-10-03: the Go tests run with the race detector and coverage (issue #88):
`backend/script/test` runs `go test -timeout 30s -race -cover ./...` and, if
that fails, runs it again with `-v` and fails. Go's `-timeout` bounds the
diff --git a/backend/script/lint b/backend/script/lint
index 720b747..b447e9e 100755
--- a/backend/script/lint
+++ b/backend/script/lint
@@ -14,16 +14,34 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+# The sha256 of the org standard .golangci.yml. When that file changes in
+# sneak/prompts and is copied here again, this changes with it.
GOLANGCI_CONFIG_SHA256="a79b63a254602a5318db5d0e9a06bc71b84bf0c1d896305229d8bfed1d1b1776"
main() {
cd "$ROOT"
+ if [ ! -f .golangci.yml ]; then
+ echo "backend/.golangci.yml is missing. Copy the org standard verbatim" >&2
+ echo "from https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml" >&2
+ exit 1
+ fi
actual="$(sha256sum .golangci.yml | cut -d' ' -f1)"
+ if [ -z "$actual" ]; then
+ echo "sha256sum is missing or printed no hash, so" >&2
+ echo "backend/.golangci.yml could not be checked." >&2
+ exit 1
+ fi
if [ "$actual" != "$GOLANGCI_CONFIG_SHA256" ]; then
- echo ".golangci.yml has drifted from the org standard." >&2
+ echo "backend/.golangci.yml does not match GOLANGCI_CONFIG_SHA256" >&2
+ echo "in backend/script/lint." >&2
echo " expected $GOLANGCI_CONFIG_SHA256" >&2
echo " actual $actual" >&2
- echo "Restore it verbatim from sneak/prompts; do not edit it." >&2
+ echo "Compare it with the org standard," >&2
+ echo "https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml" >&2
+ echo "- If they differ, it was edited here: restore the org standard" >&2
+ echo " verbatim. Do not edit it." >&2
+ echo "- If they are the same, the org standard changed: set" >&2
+ echo " GOLANGCI_CONFIG_SHA256 in backend/script/lint to the actual hash." >&2
exit 1
fi
golangci-lint run ./...
--
2.54.0
From e4df415676b822f394f70f30da310a988e4a690f Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sat, 3 Oct 2026 17:51:08 +0200
Subject: [PATCH 06/62] Delete the oldest report files to stay under the size
cap (closes #54)
When a report would take the report files past DATA_DIR_MAX_BYTES,
reportbuf now deletes the oldest report files until it fits, and does
the same at start when files left by an earlier run are already past
it. A file joins the files that may be deleted, at its place by name,
only once it is completely written, so a file still being written is
never deleted. A report is refused with 507 only when the reports
waiting to be written fill the cap on their own, and then no file is
deleted. The reports of a failed write stop counting, and the part of
its file written is removed. A file whose deletion fails keeps
counting; one already deleted by hand counts as freed.
Model: opus-5-5
---
README.md | 2 +-
TODO.md | 9 +
backend/README.md | 28 +-
backend/internal/handlers/report.go | 7 +-
backend/internal/handlers/report_test.go | 6 +-
backend/internal/reportbuf/export_test.go | 12 +-
backend/internal/reportbuf/reportbuf.go | 222 ++++++---
backend/internal/reportbuf/reportbuf_test.go | 461 +++++++++++++++++--
8 files changed, 638 insertions(+), 109 deletions(-)
diff --git a/README.md b/README.md
index e604338..d8fbffa 100644
--- a/README.md
+++ b/README.md
@@ -220,7 +220,7 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
- `REPORTS_PER_MINUTE`, default `60`: reports each client address may send a
minute
- `DATA_DIR_MAX_BYTES`, default `1073741824` (1 GiB): the most room the
- report files may take
+ report files may take; the oldest are deleted to stay under it
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
the API
- `DEBUG`, default `false`: debug logging
diff --git a/TODO.md b/TODO.md
index b494400..8e10ec8 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,15 @@ latest run passes.
# Completed Steps
+- 2026-10-03: `DATA_DIR_MAX_BYTES` is now how much of the report files is kept
+ (issue #54): when a report would take them past it, the oldest report files
+ are deleted to make room, each deletion logged, and at start files already
+ past it are deleted the same way. A file still being written is never deleted.
+ A report is refused with 507 only when the reports waiting to be written fill
+ the cap on their own, and then no file is deleted. The reports of a failed
+ write stop counting, and the part of its file written is removed. A file that
+ cannot be deleted still counts until the next start; one already deleted by
+ hand counts as freed
- 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
file with the org standard: if they differ, restore the org standard; if they
diff --git a/backend/README.md b/backend/README.md
index 0161cc4..42494ac 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -83,7 +83,7 @@ Internal packages in `internal/` follow standard Go project layout:
| `BIND_ADDRESS` | empty | IP address to listen on; empty listens on every interface |
| `PORT` | `8080` | HTTP listen port |
| `DATA_DIR` | `./data/reports` | Directory for compressed reports |
-| `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Largest total size of the report files in `DATA_DIR`; see [Report limits](#report-limits) |
+| `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Most bytes of report files kept in `DATA_DIR`, oldest deleted first; see [Report limits](#report-limits) |
| `DEBUG` | `false` | Enable debug logging |
| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
@@ -128,10 +128,11 @@ Reports are written as `reports--.jsonl.zst` files in
`DATA_DIR`. The timestamp is in UTC to the millisecond, so the names sort by
time. The number starts at 1 when the server starts and goes up by one for each
file the server starts to write, so two files written in the same millisecond
-still get different names. A failed write uses up its number, leaving a gap in
-the numbers if the file could not be created and otherwise a file under that
-number that may be incomplete. Each file contains one JSON object per line,
-compressed with zstd. Files are created with `O_EXCL` to prevent overwrites.
+still get different names. A failed write uses up its number and leaves a gap in
+the numbers: its file, if it was created, is removed. The file stays, counted
+toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
+Each file contains one JSON object per line, compressed with zstd. Files are
+created with `O_EXCL` to prevent overwrites.
### Report limits
@@ -151,11 +152,17 @@ credentials, so it is bounded instead. Both refusals below answer with the same
`X-RateLimit-Reset` headers.
- **Size cap.** The report files in `DATA_DIR` may total at most
`DATA_DIR_MAX_BYTES`, counting the files already there at start. Reports
- waiting in memory count at their uncompressed size until they are written, so
- a report that would take the total past the cap is refused with 507, and
- nothing of it is stored. Deleting report files frees room only at the next
- start, when the files are counted again. The default of 1 GiB is small enough
- for any host; set it to the space you can give `DATA_DIR`.
+ waiting in memory count at their uncompressed size until they are written;
+ those lost to a failed write stop counting, and the part of its file written
+ is removed. When a report would take the total past the cap, the oldest report
+ files are deleted to make room, and each deletion is logged with the file's
+ name and size; a file still being written is never deleted. A report is
+ refused with 507, and nothing of it is stored, only when the reports waiting
+ to be written fill the cap on their own, and then no file is deleted. At
+ start, report files past the cap, as after lowering it, are deleted the same
+ way. So the cap is how much of the newest reports is kept: the default of 1
+ GiB is small enough for any host; set it to the space you can give
+ `DATA_DIR`.
### CORS
@@ -173,7 +180,6 @@ starting, with an error naming `CORS_ALLOWED_ORIGINS`.
- Add integration test that POSTs a report and verifies the compressed output
- Add report decompression/query endpoint
- Add metrics (Prometheus) for buffer size, flush count, report count
-- Add retention policy to prune old report files
## License
diff --git a/backend/internal/handlers/report.go b/backend/internal/handlers/report.go
index e7dd4e4..8aa5ad9 100644
--- a/backend/internal/handlers/report.go
+++ b/backend/internal/handlers/report.go
@@ -86,11 +86,12 @@ func (s *Handlers) decodeErrorStatus(err error) int {
}
// appendErrorStatus logs a failure to store a report and returns
-// the status to send: 507 when the report files are at their size
-// cap, otherwise 500.
+// the status to send: 507 when the reports waiting to be written fill
+// the size cap, otherwise 500.
func (s *Handlers) appendErrorStatus(err error) int {
if errors.Is(err, reportbuf.ErrFull) {
- s.log.Warn("report refused: report files at their size cap")
+ s.log.Warn("report refused: " +
+ "reports waiting to be written fill the size cap")
return http.StatusInsufficientStorage
}
diff --git a/backend/internal/handlers/report_test.go b/backend/internal/handlers/report_test.go
index c906b52..bd6345c 100644
--- a/backend/internal/handlers/report_test.go
+++ b/backend/internal/handlers/report_test.go
@@ -125,9 +125,9 @@ func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
}
}
-// TestHandleReportFullIs507 checks the answer when the report files
-// are at their size cap: 507 and the usual error body, which tells
-// the client nothing more.
+// TestHandleReportFullIs507 checks the answer when the reports waiting
+// to be written fill the size cap: 507 and the usual error body, which
+// tells the client nothing more.
func TestHandleReportFullIs507(t *testing.T) {
t.Parallel()
diff --git a/backend/internal/reportbuf/export_test.go b/backend/internal/reportbuf/export_test.go
index 845feff..4408b9a 100644
--- a/backend/internal/reportbuf/export_test.go
+++ b/backend/internal/reportbuf/export_test.go
@@ -1,6 +1,9 @@
package reportbuf
-import "time"
+import (
+ "os"
+ "time"
+)
// FlushSizeThreshold exposes the buffer size at which Append starts
// writing a report file to the external tests.
@@ -17,3 +20,10 @@ func (b *Buffer) Flush() error {
func (b *Buffer) StopClock(at time.Time) {
b.now = func() time.Time { return at }
}
+
+// OnFileCreated makes the buffer call fn with each report file it
+// writes from now on, once the file is created and before anything is
+// written to it.
+func (b *Buffer) OnFileCreated(fn func(f *os.File)) {
+ b.fileCreated = fn
+}
diff --git a/backend/internal/reportbuf/reportbuf.go b/backend/internal/reportbuf/reportbuf.go
index eeb0184..62e6bf4 100644
--- a/backend/internal/reportbuf/reportbuf.go
+++ b/backend/internal/reportbuf/reportbuf.go
@@ -1,5 +1,6 @@
// Package reportbuf accumulates telemetry reports in memory
-// and periodically flushes them to zstd-compressed JSONL files.
+// and periodically flushes them to zstd-compressed JSONL files,
+// deleting the oldest files to keep them under a size cap.
package reportbuf
import (
@@ -12,6 +13,7 @@ import (
"log/slog"
"os"
"path/filepath"
+ "slices"
"strings"
"sync"
"sync/atomic"
@@ -37,9 +39,10 @@ const (
fileSuffix = ".jsonl.zst"
)
-// ErrFull is returned by Append when storing the report would
-// take the report files past the configured maximum size.
-var ErrFull = errors.New("report files at their size cap")
+// ErrFull is returned by Append when the reports waiting to be
+// written leave no room for the report under the configured maximum
+// size, however many report files are deleted.
+var ErrFull = errors.New("reports waiting to be written fill the size cap")
// Params defines the dependencies for Buffer.
type Params struct {
@@ -49,15 +52,34 @@ type Params struct {
Logger *logger.Logger
}
+// reportFile is a report file that may be deleted to make room, with
+// the size it counts for in usedBytes.
+type reportFile struct {
+ name string
+ size int64
+}
+
// Buffer accumulates JSON lines in memory and flushes them
// to zstd-compressed files on disk.
type Buffer struct {
- buf bytes.Buffer
- dataDir string
- done chan struct{}
- log *slog.Logger
- maxBytes int64
- mu sync.Mutex
+ buf bytes.Buffer
+ dataDir string
+ done chan struct{}
+ // fileCreated is called with each report file once it is
+ // created, before anything is written to it: it does nothing,
+ // except in tests that hold the write open or make it fail.
+ fileCreated func(f *os.File)
+ // files are the report files that may be deleted to make room,
+ // in name order, which is oldest first: those in dataDir at
+ // start, and each one this buffer writes, put in at its place by
+ // name once it is complete, even when an older file's write
+ // completes after a newer one's. A file still being written is
+ // not among them. filesBytes is their total size.
+ files []reportFile
+ filesBytes int64
+ log *slog.Logger
+ maxBytes int64
+ mu sync.Mutex
// now is the clock report files are named by: time.Now, except
// in tests that need two flushes to share a timestamp.
now func() time.Time
@@ -66,8 +88,8 @@ type Buffer struct {
seq atomic.Uint64
stopOnce sync.Once
// usedBytes is what Append checks against maxBytes: the size
- // of the report files in dataDir, plus the reports not yet
- // written to one at their uncompressed size.
+ // of the report files in dataDir, plus the reports waiting to
+ // be written to one at their uncompressed size.
usedBytes int64
}
@@ -83,11 +105,12 @@ func New(
}
b := &Buffer{
- dataDir: dir,
- done: make(chan struct{}),
- log: params.Logger.Get(),
- maxBytes: params.Config.DataDirMaxBytes,
- now: time.Now,
+ dataDir: dir,
+ done: make(chan struct{}),
+ fileCreated: func(*os.File) {},
+ log: params.Logger.Get(),
+ maxBytes: params.Config.DataDirMaxBytes,
+ now: time.Now,
}
lc.Append(fx.Hook{
@@ -97,12 +120,28 @@ func New(
return fmt.Errorf("create data dir: %w", err)
}
- // Report files left by earlier runs count too.
- b.usedBytes, err = reportFilesSize(b.dataDir)
+ // Report files left by earlier runs count too, and are
+ // the first to be deleted to make room.
+ files, err := reportFiles(b.dataDir)
if err != nil {
return err
}
+ b.mu.Lock()
+
+ b.files = files
+ for _, f := range files {
+ b.filesBytes += f.size
+ }
+
+ b.usedBytes = b.filesBytes
+
+ // The files may be past the cap, if it was lowered since
+ // the last run.
+ b.deleteOldestFiles(0)
+
+ b.mu.Unlock()
+
go b.flushLoop()
return nil
@@ -128,9 +167,10 @@ func New(
}
// Append marshals v as a single JSON line and appends it to
-// the buffer. It stores nothing and returns ErrFull if the line
-// would take usedBytes past maxBytes. If the buffer reaches the
-// size threshold, it is drained and written to disk
+// the buffer. If the line would take usedBytes past maxBytes, the
+// oldest report files are deleted to make room; it stores nothing
+// and returns ErrFull if that cannot make room. If the buffer
+// reaches the size threshold, it is drained and written to disk
// asynchronously.
func (b *Buffer) Append(v any) error {
line, err := json.Marshal(v)
@@ -142,6 +182,8 @@ func (b *Buffer) Append(v any) error {
b.mu.Lock()
+ b.deleteOldestFiles(lineBytes)
+
if b.usedBytes+lineBytes > b.maxBytes {
b.mu.Unlock()
@@ -171,6 +213,37 @@ func (b *Buffer) Append(v any) error {
return nil
}
+// deleteOldestFiles deletes report files, oldest first, until n more
+// bytes fit under maxBytes. It deletes none when the reports waiting
+// to be written leave no room for n even with every file gone, since
+// that would lose the files for nothing. The caller must hold b.mu.
+func (b *Buffer) deleteOldestFiles(n int64) {
+ for b.usedBytes+n > b.maxBytes && len(b.files) > 0 {
+ if b.usedBytes-b.filesBytes+n > b.maxBytes {
+ return
+ }
+
+ f := b.files[0]
+ b.files = b.files[1:]
+ b.filesBytes -= f.size
+
+ // A file already gone, deleted by hand, has freed its room too.
+ err := os.Remove(filepath.Join(b.dataDir, f.name))
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ // The file is still there, so it still counts. It is
+ // not tried again until the next start.
+ b.log.Error("delete report file failed",
+ "file", f.name, "error", err)
+
+ continue
+ }
+
+ b.usedBytes -= f.size
+ b.log.Info("deleted report file to make room",
+ "file", f.name, "bytes", f.size)
+ }
+}
+
// flushLoop runs a ticker that periodically flushes buffered
// data to disk until the done channel is closed.
func (b *Buffer) flushLoop() {
@@ -217,15 +290,48 @@ func (b *Buffer) drainBuf() []byte {
return data
}
-// writeFile creates a timestamped zstd-compressed JSONL file
-// in the data directory.
+// writeFile writes data, reports drained from the buffer, to a new
+// timestamped zstd-compressed JSONL file in the data directory.
func (b *Buffer) writeFile(data []byte) error {
// The timestamp comes first, so the names sort by time; the number
// after it tells apart files named in the same millisecond.
ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z")
name := fmt.Sprintf("%s%s-%d%s", filePrefix, ts, b.seq.Add(1), fileSuffix)
- path := filepath.Join(b.dataDir, name)
+ size, err := b.createFile(filepath.Join(b.dataDir, name), data)
+
+ // The reports no longer wait to be written, so they stop counting
+ // at their uncompressed size. If the write failed they are lost;
+ // otherwise they count as the file, which from here on may be
+ // deleted to make room.
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ b.usedBytes -= int64(len(data))
+
+ if err != nil {
+ return err
+ }
+
+ b.usedBytes += size
+ b.filesBytes += size
+
+ // At its place by name, not at the end: another write, of a newer
+ // file, may have completed while this one was being written.
+ i, _ := slices.BinarySearchFunc(b.files, name,
+ func(f reportFile, target string) int {
+ return strings.Compare(f.name, target)
+ })
+ b.files = slices.Insert(b.files, i, reportFile{name: name, size: size})
+
+ return nil
+}
+
+// createFile creates the file at path holding data compressed with
+// zstd, and returns its size. If the write fails once the file is
+// created, it removes the file, so that a failed write leaves nothing
+// behind to take room.
+func (b *Buffer) createFile(path string, data []byte) (int64, error) {
// path is built from the operator-supplied dataDir plus a
// generated timestamp and number, so it carries no external input.
f, err := os.OpenFile( //nolint:gosec // see comment above
@@ -234,61 +340,65 @@ func (b *Buffer) writeFile(data []byte) error {
filePerms,
)
if err != nil {
- return fmt.Errorf("create report file: %w", err)
+ return 0, fmt.Errorf("create report file: %w", err)
}
- // Closes the file on the early returns below. The success
- // path closes it explicitly to check the error; closing it
- // a second time here is harmless.
- defer func() { _ = f.Close() }()
+ b.fileCreated(f)
+ size, err := writeCompressed(f, data)
+ if err != nil {
+ _ = f.Close()
+
+ return 0, errors.Join(err, os.Remove(path))
+ }
+
+ err = f.Close()
+ if err != nil {
+ err = fmt.Errorf("close report file: %w", err)
+
+ return 0, errors.Join(err, os.Remove(path))
+ }
+
+ return size, nil
+}
+
+// writeCompressed writes data to f compressed with zstd, and returns
+// the size of f.
+func writeCompressed(f *os.File, data []byte) (int64, error) {
enc, err := zstd.NewWriter(f)
if err != nil {
- return fmt.Errorf("create zstd encoder: %w", err)
+ return 0, fmt.Errorf("create zstd encoder: %w", err)
}
_, err = enc.Write(data)
if err != nil {
_ = enc.Close()
- return fmt.Errorf("write compressed data: %w", err)
+ return 0, fmt.Errorf("write compressed data: %w", err)
}
err = enc.Close()
if err != nil {
- return fmt.Errorf("close zstd encoder: %w", err)
+ return 0, fmt.Errorf("close zstd encoder: %w", err)
}
info, err := f.Stat()
if err != nil {
- return fmt.Errorf("stat report file: %w", err)
+ return 0, fmt.Errorf("stat report file: %w", err)
}
- err = f.Close()
- if err != nil {
- return fmt.Errorf("close report file: %w", err)
- }
-
- // The reports counted at their uncompressed size while they
- // waited; now they count as the file. After a failed write they
- // stay counted as they were, which errs toward refusing reports
- // early rather than letting the files pass the cap.
- b.mu.Lock()
- b.usedBytes += info.Size() - int64(len(data))
- b.mu.Unlock()
-
- return nil
+ return info.Size(), nil
}
-// reportFilesSize returns the total size of the report files in
-// dir.
-func reportFilesSize(dir string) (int64, error) {
+// reportFiles returns the report files in dir, oldest first:
+// os.ReadDir sorts them by name, and the names sort by time.
+func reportFiles(dir string) ([]reportFile, error) {
entries, err := os.ReadDir(dir)
if err != nil {
- return 0, fmt.Errorf("read data dir: %w", err)
+ return nil, fmt.Errorf("read data dir: %w", err)
}
- var total int64
+ files := make([]reportFile, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
@@ -299,11 +409,11 @@ func reportFilesSize(dir string) (int64, error) {
info, err := entry.Info()
if err != nil {
- return 0, fmt.Errorf("stat report file: %w", err)
+ return nil, fmt.Errorf("stat report file: %w", err)
}
- total += info.Size()
+ files = append(files, reportFile{name: name, size: info.Size()})
}
- return total, nil
+ return files, nil
}
diff --git a/backend/internal/reportbuf/reportbuf_test.go b/backend/internal/reportbuf/reportbuf_test.go
index f9b020e..7d679f9 100644
--- a/backend/internal/reportbuf/reportbuf_test.go
+++ b/backend/internal/reportbuf/reportbuf_test.go
@@ -139,11 +139,19 @@ func lineBytes(t *testing.T, report any) int {
return len(line) + 1
}
+// TestAppendPastCapIsRefused fills the cap with a report not yet
+// written. The next report is refused, and the report file already in
+// DATA_DIR is kept: it is smaller than a report, so deleting it could
+// not make room.
func TestAppendPastCapIsRefused(t *testing.T) {
report := map[string]string{"id": "cap"}
+ dir := t.TempDir()
+ earlier := reportFilePath(dir, 1)
- t.Setenv("DATA_DIR", t.TempDir())
- t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
+ writeBytes(t, earlier, 1)
+
+ t.Setenv("DATA_DIR", dir)
+ t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(1+lineBytes(t, report)))
buf := startBuffer(t)
@@ -156,24 +164,38 @@ func TestAppendPastCapIsRefused(t *testing.T) {
if !errors.Is(err, reportbuf.ErrFull) {
t.Fatalf("report past the cap: error = %v, want ErrFull", err)
}
+
+ if !exists(t, earlier) {
+ t.Fatal("report file deleted, though that could not make room")
+ }
}
-// TestCapCountsReportFilesAlreadyInDataDir starts on a data
-// directory holding a report file from an earlier run, and a file
-// that is not a report, which must not count.
-func TestCapCountsReportFilesAlreadyInDataDir(t *testing.T) {
- const earlierBytes = 100
+// TestOldestReportFileDeletedFirst starts on a data directory holding
+// report files from an earlier run, and a file that is not a report,
+// which neither counts nor is ever deleted. Nothing is deleted while
+// there is room; then only the oldest report file is.
+func TestOldestReportFileDeletedFirst(t *testing.T) {
+ const fileBytes = 100
- report := map[string]string{"id": "cap"}
+ report := map[string]string{"id": "oldest"}
dir := t.TempDir()
+ oldest := reportFilePath(dir, 1)
+ kept := []string{
+ reportFilePath(dir, 2),
+ reportFilePath(dir, 3),
+ filepath.Join(dir, "notes.txt"),
+ }
- writeBytes(t, filepath.Join(dir, "reports-2026-01-01T00-00-00.000Z.jsonl.zst"),
- earlierBytes)
- writeBytes(t, filepath.Join(dir, "notes.txt"), 10*earlierBytes)
+ writeBytes(t, oldest, fileBytes)
+
+ for _, path := range kept {
+ writeBytes(t, path, fileBytes)
+ }
t.Setenv("DATA_DIR", dir)
+ // Room for the three report files and one report.
t.Setenv("DATA_DIR_MAX_BYTES",
- strconv.Itoa(earlierBytes+lineBytes(t, report)))
+ strconv.Itoa(3*fileBytes+lineBytes(t, report)))
buf := startBuffer(t)
@@ -182,9 +204,55 @@ func TestCapCountsReportFilesAlreadyInDataDir(t *testing.T) {
t.Fatalf("report that fills the cap exactly: %v", err)
}
+ if !exists(t, oldest) {
+ t.Fatal("oldest report file deleted while there was room")
+ }
+
err = buf.Append(report)
- if !errors.Is(err, reportbuf.ErrFull) {
- t.Fatalf("report past the cap: error = %v, want ErrFull", err)
+ if err != nil {
+ t.Fatalf("report past the cap: %v", err)
+ }
+
+ if exists(t, oldest) {
+ t.Fatal("oldest report file kept when room was needed")
+ }
+
+ for _, path := range kept {
+ if !exists(t, path) {
+ t.Fatalf("%s deleted; only the oldest report file should be", path)
+ }
+ }
+}
+
+// TestStartDeletesFilesPastCap starts on report files past the cap,
+// as after the cap is lowered: the oldest are deleted until the rest
+// fit.
+func TestStartDeletesFilesPastCap(t *testing.T) {
+ const fileBytes = 100
+
+ dir := t.TempDir()
+ oldest := reportFilePath(dir, 1)
+ kept := []string{reportFilePath(dir, 2), reportFilePath(dir, 3)}
+
+ writeBytes(t, oldest, fileBytes)
+
+ for _, path := range kept {
+ writeBytes(t, path, fileBytes)
+ }
+
+ t.Setenv("DATA_DIR", dir)
+ t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(len(kept)*fileBytes))
+
+ startBuffer(t)
+
+ if exists(t, oldest) {
+ t.Fatal("oldest report file kept, though the files were past the cap")
+ }
+
+ for _, path := range kept {
+ if !exists(t, path) {
+ t.Fatalf("%s deleted, though the rest fit without it", path)
+ }
}
}
@@ -195,8 +263,9 @@ func TestWrittenReportsCountAtFileSize(t *testing.T) {
// Repetitive, so its file is far smaller than its JSON.
report := map[string]string{"id": strings.Repeat("a", 1000)}
size := lineBytes(t, report)
+ dir := t.TempDir()
- t.Setenv("DATA_DIR", t.TempDir())
+ t.Setenv("DATA_DIR", dir)
// Room for the report twice over only if the first one counts
// at its file's size by the time the second arrives.
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*size-1))
@@ -217,13 +286,22 @@ func TestWrittenReportsCountAtFileSize(t *testing.T) {
if err != nil {
t.Fatalf("second report, after the first was written: %v", err)
}
+
+ if !hasReportFile(t, dir) {
+ t.Fatal("first report's file deleted, though the second fit beside it")
+ }
}
-// TestWrittenReportsKeepCounting writes one report file after another
-// under a small cap: each report must be taken while the files on disk
-// leave room for it, and refused once they do not.
-func TestWrittenReportsKeepCounting(t *testing.T) {
- const maxBytes = 200
+// TestWrittenFilesDeletedToMakeRoom writes one report file after
+// another under a small cap. Every report must be taken; files are
+// deleted only when the report would not fit beside them, and the
+// files kept leave room for it.
+func TestWrittenFilesDeletedToMakeRoom(t *testing.T) {
+ const (
+ maxBytes = 200
+ // One file each, which take far more than maxBytes together.
+ reports = 50
+ )
report := map[string]string{"id": "written"}
size := int64(lineBytes(t, report))
@@ -234,23 +312,24 @@ func TestWrittenReportsKeepCounting(t *testing.T) {
buf := startBuffer(t)
- // Every file takes at least a byte, so they fill the cap within
- // maxBytes rounds.
- for range maxBytes {
- used := reportFilesBytes(t, dir)
+ for range reports {
+ before := reportFilesBytes(t, dir)
err := buf.Append(report)
- if used+size > maxBytes {
- if !errors.Is(err, reportbuf.ErrFull) {
- t.Fatalf("with %d bytes of report files: error = %v, "+
- "want ErrFull", used, err)
- }
-
- return
+ if err != nil {
+ t.Fatalf("with %d bytes of report files: %v", before, err)
}
- if err != nil {
- t.Fatalf("with %d bytes of report files: %v", used, err)
+ after := reportFilesBytes(t, dir)
+
+ if before+size <= maxBytes && after != before {
+ t.Fatalf("files deleted, though the report fit beside "+
+ "their %d bytes", before)
+ }
+
+ if after+size > maxBytes {
+ t.Fatalf("%d bytes of report files kept, leaving no room "+
+ "for the report", after)
}
err = buf.Flush()
@@ -258,8 +337,217 @@ func TestWrittenReportsKeepCounting(t *testing.T) {
t.Fatalf("flush: %v", err)
}
}
+}
- t.Fatal("the report files never filled the cap")
+// TestFailedDeletionStillCounts makes deleting the oldest report file
+// fail. It is still there, so it still takes room, and the next oldest
+// is deleted in its place.
+func TestFailedDeletionStillCounts(t *testing.T) {
+ const fileBytes = 100
+
+ report := map[string]string{"id": "stuck"}
+ dir := t.TempDir()
+ stuck := reportFilePath(dir, 1)
+ next := reportFilePath(dir, 2)
+ newest := reportFilePath(dir, 3)
+
+ for _, path := range []string{stuck, next, newest} {
+ writeBytes(t, path, fileBytes)
+ }
+
+ t.Setenv("DATA_DIR", dir)
+ t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(3*fileBytes))
+
+ buf := startBuffer(t)
+
+ // A directory that is not empty cannot be deleted, even by root,
+ // which the tests run as in the backend image.
+ err := os.Remove(stuck)
+ if err != nil {
+ t.Fatalf("remove %s: %v", stuck, err)
+ }
+
+ err = os.Mkdir(stuck, 0o750)
+ if err != nil {
+ t.Fatalf("make directory %s: %v", stuck, err)
+ }
+
+ writeBytes(t, filepath.Join(stuck, "file"), 1)
+
+ err = buf.Append(report)
+ if err != nil {
+ t.Fatalf("report past the cap: %v", err)
+ }
+
+ if exists(t, next) {
+ t.Fatal("next oldest report file kept: the failed deletion " +
+ "counted as making room")
+ }
+
+ if !exists(t, newest) {
+ t.Fatal("newest report file deleted, though deleting one made room")
+ }
+}
+
+// TestFileDeletedByHandFreesRoom deletes the oldest report file by
+// hand after start. When room is needed, its room counts as freed, so
+// no other file is deleted.
+func TestFileDeletedByHandFreesRoom(t *testing.T) {
+ const fileBytes = 100
+
+ report := map[string]string{"id": "by-hand"}
+ dir := t.TempDir()
+ gone := reportFilePath(dir, 1)
+ kept := reportFilePath(dir, 2)
+
+ writeBytes(t, gone, fileBytes)
+ writeBytes(t, kept, fileBytes)
+
+ t.Setenv("DATA_DIR", dir)
+ t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*fileBytes))
+
+ buf := startBuffer(t)
+
+ err := os.Remove(gone)
+ if err != nil {
+ t.Fatalf("remove %s: %v", gone, err)
+ }
+
+ err = buf.Append(report)
+ if err != nil {
+ t.Fatalf("report past the cap: %v", err)
+ }
+
+ if !exists(t, kept) {
+ t.Fatal("report file deleted, though the one deleted by hand " +
+ "had made room")
+ }
+}
+
+// TestFileBeingWrittenIsNeverDeleted holds the write of one report file
+// open while a second write completes, then sends a report that needs
+// room. Deleting either file would make it, and the one being written is
+// the older, but only the complete one may be deleted. Once the first
+// write is complete, its file is deleted when room is needed.
+func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
+ report := map[string]string{"id": "writing"}
+
+ t.Setenv("DATA_DIR", t.TempDir())
+ // Room for two reports waiting to be written, but not for two
+ // beside a report file.
+ t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*lineBytes(t, report)))
+
+ buf := startBuffer(t)
+
+ created := make(chan string)
+ release := make(chan struct{})
+
+ buf.OnFileCreated(func(f *os.File) {
+ created <- f.Name()
+
+ <-release
+ })
+
+ err := buf.Append(report)
+ if err != nil {
+ t.Fatalf("first report: %v", err)
+ }
+
+ flushed := make(chan error)
+
+ go func() { flushed <- buf.Flush() }()
+
+ writing := <-created
+
+ // Only the first write is held; the second goes through, and
+ // so do the writes after it, the final one at stop included.
+ var complete string
+
+ buf.OnFileCreated(func(f *os.File) { complete = f.Name() })
+
+ // Errorf, not Fatalf, until the first write is released, so that a
+ // failure here does not leave it held.
+ err = buf.Append(report)
+ if err != nil {
+ t.Errorf("second report: %v", err)
+ }
+
+ err = buf.Flush()
+ if err != nil {
+ t.Errorf("flush of the second report: %v", err)
+ }
+
+ err = buf.Append(report)
+ if err != nil {
+ t.Errorf("report that needs room: %v", err)
+ }
+
+ if !exists(t, writing) {
+ t.Error("report file deleted while it was being written")
+ }
+
+ if exists(t, complete) {
+ t.Error("complete report file kept, though room was needed")
+ }
+
+ close(release)
+
+ err = <-flushed
+ if err != nil {
+ t.Fatalf("flush of the first report: %v", err)
+ }
+
+ err = buf.Append(report)
+ if err != nil {
+ t.Fatalf("report after the first write was complete: %v", err)
+ }
+
+ if exists(t, writing) {
+ t.Fatal("complete report file kept when room was needed")
+ }
+}
+
+// TestFailedWriteStopsCounting makes a write fail once its file is
+// created. Its reports are lost, so they stop counting, and the part of
+// the file written is removed, so it takes no room.
+func TestFailedWriteStopsCounting(t *testing.T) {
+ report := map[string]string{"id": "failed"}
+
+ t.Setenv("DATA_DIR", t.TempDir())
+ t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
+
+ buf := startBuffer(t)
+
+ var failed string
+
+ // Closing the file under the write makes the write fail.
+ buf.OnFileCreated(func(f *os.File) {
+ failed = f.Name()
+
+ _ = f.Close()
+ })
+
+ err := buf.Append(report)
+ if err != nil {
+ t.Fatalf("report that fills the cap exactly: %v", err)
+ }
+
+ err = buf.Flush()
+ if err == nil {
+ t.Fatal("flush succeeded, though its file was closed under it")
+ }
+
+ if exists(t, failed) {
+ t.Fatal("file of the failed write kept")
+ }
+
+ // Writes from here on, the final one at stop included, succeed.
+ buf.OnFileCreated(func(*os.File) {})
+
+ err = buf.Append(report)
+ if err != nil {
+ t.Fatalf("report after the failed write: %v", err)
+ }
}
// TestConcurrentAppendsStopAtCap appends from many goroutines at once
@@ -492,6 +780,14 @@ func readReportFiles(dir string) ([]string, error) {
return contents, nil
}
+// reportFilePath returns the path in dir of a report file named as
+// written on the given day of January 2026, so that a lower day sorts
+// as older.
+func reportFilePath(dir string, day int) string {
+ return filepath.Join(dir,
+ fmt.Sprintf("reports-2026-01-%02dT00-00-00.000Z-1.jsonl.zst", day))
+}
+
func writeBytes(t *testing.T, path string, n int) {
t.Helper()
@@ -501,6 +797,21 @@ func writeBytes(t *testing.T, path string, n int) {
}
}
+func exists(t *testing.T, path string) bool {
+ t.Helper()
+
+ _, err := os.Stat(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return false
+ }
+
+ if err != nil {
+ t.Fatalf("stat %s: %v", path, err)
+ }
+
+ return true
+}
+
func hasReportFile(t *testing.T, dir string) bool {
t.Helper()
@@ -524,3 +835,85 @@ func hasReportFile(t *testing.T, dir string) bool {
return false
}
+
+// TestFilesDeletedOldestFirstWhenWritesOverlap holds the write of an
+// older report file open until a newer one's write completes, then
+// releases it. When room is needed, the older file is deleted first,
+// though its write was the last to complete.
+func TestFilesDeletedOldestFirstWhenWritesOverlap(t *testing.T) {
+ const maxBytes = 1000
+
+ report := map[string]string{"id": "overlap"}
+
+ t.Setenv("DATA_DIR", t.TempDir())
+ t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes))
+
+ buf := startBuffer(t)
+
+ created := make(chan string)
+ release := make(chan struct{})
+
+ buf.OnFileCreated(func(f *os.File) {
+ created <- f.Name()
+
+ <-release
+ })
+
+ err := buf.Append(report)
+ if err != nil {
+ t.Fatalf("older report: %v", err)
+ }
+
+ flushed := make(chan error)
+
+ go func() { flushed <- buf.Flush() }()
+
+ older := <-created
+
+ // Only the older write is held; the newer one goes through, and
+ // so do the writes after it, the final one at stop included.
+ var newer string
+
+ buf.OnFileCreated(func(f *os.File) { newer = f.Name() })
+
+ // Errorf, not Fatalf, until the older write is released, so that a
+ // failure here does not leave it held.
+ err = buf.Append(report)
+ if err != nil {
+ t.Errorf("newer report: %v", err)
+ }
+
+ err = buf.Flush()
+ if err != nil {
+ t.Errorf("flush of the newer report: %v", err)
+ }
+
+ close(release)
+
+ err = <-flushed
+ if err != nil {
+ t.Fatalf("flush of the older report: %v", err)
+ }
+
+ info, err := os.Stat(newer)
+ if err != nil {
+ t.Fatalf("stat %s: %v", newer, err)
+ }
+
+ // A report that fits beside the newer file alone, so deleting the
+ // older one makes exactly the room it needs.
+ pad := maxBytes - int(info.Size()) - lineBytes(t, map[string]string{"id": ""})
+
+ err = buf.Append(map[string]string{"id": strings.Repeat("a", pad)})
+ if err != nil {
+ t.Fatalf("report that needs room: %v", err)
+ }
+
+ if exists(t, older) {
+ t.Fatal("older report file kept when room was needed")
+ }
+
+ if !exists(t, newer) {
+ t.Fatal("newer report file deleted before the older one")
+ }
+}
--
2.54.0
From 39ee6ca839ff60d629d3a7e0ce161b05872e951a Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sat, 3 Oct 2026 18:24:38 +0200
Subject: [PATCH 07/62] Entrypoint acts as root on nothing outside /data
(closes #80)
`bin/entrypoint.sh` now runs `netwatch-server prepare-data-dir`, which
refuses a `DATA_DIR` that is not `/data` or a path below it written in
full, then creates `DATA_DIR`, gives `/data` and everything in it to
`netwatch`, and sets mode 750 on `/data` and `DATA_DIR`. Every step goes
through a Go `os.Root` opened on `/data`, and the modes are set on the
opened directories rather than by name, so neither a symbolic link
already there nor one a host process swaps in while the container
starts can make root create or change anything outside `/data`. The
README says which `DATA_DIR` values are accepted.
Model: opus-5-5
---
README.md | 6 +-
TODO.md | 10 +
backend/README.md | 5 +-
backend/cmd/netwatch-server/main.go | 21 ++
backend/internal/reportbuf/datadir.go | 150 ++++++++++
backend/internal/reportbuf/datadir_test.go | 307 +++++++++++++++++++++
bin/entrypoint.sh | 25 +-
7 files changed, 501 insertions(+), 23 deletions(-)
create mode 100644 backend/internal/reportbuf/datadir.go
create mode 100644 backend/internal/reportbuf/datadir_test.go
diff --git a/README.md b/README.md
index d8fbffa..d124263 100644
--- a/README.md
+++ b/README.md
@@ -224,8 +224,10 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
the API
- `DEBUG`, default `false`: debug logging
- - `DATA_DIR`, default `/data/reports`: leave unset; reports kept outside
- `/data` do not survive a redeploy
+ - `DATA_DIR`, default `/data/reports`: the directory the reports are kept
+ in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
+ The container also stops if the path goes through a symbolic link that
+ leads out of `/data` or is written as a full path
- `TRUSTED_PROXIES`, default empty: set it to the address the reverse proxy
in front of the container connects from, as an IP address or CIDR; several
are separated by commas. nginx takes the client address from
diff --git a/TODO.md b/TODO.md
index 8e10ec8..b4aee6d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,16 @@ latest run passes.
# Completed Steps
+- 2026-10-03: root no longer acts outside `/data` when it prepares `DATA_DIR`
+ (issue #80): `bin/entrypoint.sh` runs `netwatch-server prepare-data-dir`,
+ which refuses a `DATA_DIR` that is not `/data` or a path below it written in
+ full, then creates `DATA_DIR`, gives `/data` and everything in it to
+ `netwatch` and sets the modes, all through a Go `os.Root` opened on `/data`.
+ That refuses any path leading out of `/data`, so neither a symbolic link
+ already there nor one a host process swaps in during the start can make root
+ create or change anything elsewhere, and `DATA_DIR=/etc` no longer gives
+ `/etc` to `netwatch`. The `README.md` section "Running under upaas" says which
+ values are accepted
- 2026-10-03: `DATA_DIR_MAX_BYTES` is now how much of the report files is kept
(issue #54): when a report would take them past it, the oldest report files
are deleted to make room, each deletion logged, and at start files already
diff --git a/backend/README.md b/backend/README.md
index 42494ac..42faf05 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -107,8 +107,9 @@ this server. The image's entrypoint, `bin/entrypoint.sh`, starts the server as
user `netwatch` (uid 1000) with `BIND_ADDRESS=127.0.0.1` and `PORT=8081`, so
only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the
client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on
-the `/data` volume; the entrypoint creates it and gives it and `/data` to
-`netwatch` before starting the server. nginx replaces the security headers
+the `/data` volume; before starting the server, the entrypoint creates it and
+gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
+which acts on nothing outside `/data`. nginx replaces the security headers
this server sets with those in the root `security-headers.conf`, so those are
what clients of the image see.
diff --git a/backend/cmd/netwatch-server/main.go b/backend/cmd/netwatch-server/main.go
index 7410367..0136dae 100644
--- a/backend/cmd/netwatch-server/main.go
+++ b/backend/cmd/netwatch-server/main.go
@@ -4,6 +4,7 @@ package main
import (
"fmt"
"os"
+ "os/user"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
@@ -37,6 +38,26 @@ func main() {
return
}
+ // "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
+ // for the netwatch user, or exits 1 with the error; see
+ // reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
+ // before it starts this server as that user.
+ if len(os.Args) == 3 && os.Args[1] == "prepare-data-dir" {
+ netwatch, err := user.Lookup("netwatch")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ err = reportbuf.PrepareDataDir("/data", os.Args[2], netwatch)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "DATA_DIR '%s': %v\n", os.Args[2], err)
+ os.Exit(1)
+ }
+
+ return
+ }
+
globals.Appname = Appname
globals.Version = Version
diff --git a/backend/internal/reportbuf/datadir.go b/backend/internal/reportbuf/datadir.go
new file mode 100644
index 0000000..c2fb9f6
--- /dev/null
+++ b/backend/internal/reportbuf/datadir.go
@@ -0,0 +1,150 @@
+package reportbuf
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "syscall"
+)
+
+// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR
+// that is not the volume or a path below it, written in full.
+var ErrDataDirOutsideVolume = errors.New(
+ "must be /data or a path below it, with no '.', '..' or extra '/'")
+
+// PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the
+// user the server runs as, so that a host directory mounted at volume,
+// /data in the image, needs no preparing: it creates dir, gives volume
+// and everything in it to owner, and gives volume and dir the mode the
+// server gives a directory it creates. dir must be volume or a path
+// below it, with no '.', '..', empty part or '/' at the end.
+//
+// bin/entrypoint.sh runs this as root, which would follow a symbolic
+// link anywhere, so every step goes through an os.Root opened on
+// volume: it follows a link only when it is written as a relative
+// path that stays inside volume, and refuses any other. A process on
+// the host can swap a link onto a path in volume at any moment while
+// this runs. Even then, the os.Root checks each link as it reaches
+// it. MkdirAll creates each directory inside a parent it already has
+// open, never following a link at the name it creates, and follows a
+// link on the path only as the os.Root allows, so a relative link
+// inside volume can lead it to create directories elsewhere inside
+// volume. Lchown never changes what a link points to, and the modes
+// are set on directories already opened (see chmodDir), so the most
+// that process can do is make a step fail or wait, or act on
+// something else inside volume.
+func PrepareDataDir(volume, dir string, owner *user.User) error {
+ // rel is dir as a path from volume; IsLocal is false for one that
+ // leads out of it.
+ rel, err := filepath.Rel(volume, dir)
+ if err != nil || dir != filepath.Clean(dir) || !filepath.IsLocal(rel) {
+ return ErrDataDirOutsideVolume
+ }
+
+ uid, err := strconv.Atoi(owner.Uid)
+ if err != nil {
+ return err
+ }
+
+ gid, err := strconv.Atoi(owner.Gid)
+ if err != nil {
+ return err
+ }
+
+ root, err := os.OpenRoot(volume)
+ if err != nil {
+ return err
+ }
+
+ defer func() { _ = root.Close() }()
+
+ err = root.MkdirAll(rel, dirPerms)
+ if err != nil {
+ return err
+ }
+
+ err = lchownAll(root, ".", uid, gid)
+ if err != nil {
+ return err
+ }
+
+ err = chmodDir(root, ".")
+ if err != nil {
+ return err
+ }
+
+ return chmodDir(root, rel)
+}
+
+// lchownAll gives name, a directory inside root, and everything in it
+// to uid and gid. It reads each directory opened through root, not
+// through root.FS(), which refuses a name that is not valid UTF-8, and
+// calls Lchown on every entry, which gives a symbolic link itself to
+// them, not what it points to. It goes into an entry only when the
+// read found a directory there, so it follows no link it finds; one
+// swapped in for that directory afterwards is followed only as the
+// os.Root allows.
+func lchownAll(root *os.Root, name string, uid, gid int) error {
+ err := root.Lchown(name, uid, gid)
+ if err != nil {
+ return err
+ }
+
+ dir, err := root.Open(name)
+ if err != nil {
+ return err
+ }
+
+ entries, err := dir.ReadDir(-1)
+ _ = dir.Close()
+
+ if err != nil {
+ return err
+ }
+
+ for _, entry := range entries {
+ entryName := filepath.Join(name, entry.Name())
+ if entry.IsDir() {
+ err = lchownAll(root, entryName, uid, gid)
+ } else {
+ err = root.Lchown(entryName, uid, gid)
+ }
+
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// chmodDir gives name, a directory inside root, the mode the server
+// gives a directory it creates. Root.Chmod would not hold: on Linux it
+// checks that name is not a symbolic link, then sets the mode by name,
+// following a link swapped in between. So chmodDir opens name through
+// root and sets the mode on the open directory. It refuses anything
+// but a directory: a directory has no second name (hard link), so the
+// one opened is inside root, where any other file could be a hard link
+// to one outside.
+func chmodDir(root *os.Root, name string) error {
+ dir, err := root.Open(name)
+ if err != nil {
+ return err
+ }
+
+ defer func() { _ = dir.Close() }()
+
+ info, err := dir.Stat()
+ if err != nil {
+ return err
+ }
+
+ if !info.IsDir() {
+ return &fs.PathError{Op: "chmod", Path: name, Err: syscall.ENOTDIR}
+ }
+
+ return dir.Chmod(dirPerms)
+}
diff --git a/backend/internal/reportbuf/datadir_test.go b/backend/internal/reportbuf/datadir_test.go
new file mode 100644
index 0000000..86b2048
--- /dev/null
+++ b/backend/internal/reportbuf/datadir_test.go
@@ -0,0 +1,307 @@
+package reportbuf_test
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "syscall"
+ "testing"
+
+ "sneak.berlin/go/netwatch/internal/reportbuf"
+)
+
+// reports is the last part of DATA_DIR in these tests, as in the
+// image's /data/reports.
+const reports = "reports"
+
+// currentUser is the user the test runs as, the only owner a test not
+// run as root can give files to.
+func currentUser() *user.User {
+ return &user.User{
+ Uid: strconv.Itoa(os.Getuid()),
+ Gid: strconv.Itoa(os.Getgid()),
+ }
+}
+
+// tempDirMode700 is a new directory in a t.TempDir with mode 0700, so
+// a test can tell that PrepareDataDir left its mode alone.
+func tempDirMode700(t *testing.T) string {
+ t.Helper()
+
+ dir := filepath.Join(t.TempDir(), "d")
+
+ err := os.Mkdir(dir, 0o700)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return dir
+}
+
+func requireMode(t *testing.T, path string, want fs.FileMode) {
+ t.Helper()
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if info.Mode() != want {
+ t.Errorf("%s: mode %v, want %v", path, info.Mode(), want)
+ }
+}
+
+func requireMissing(t *testing.T, path string) {
+ t.Helper()
+
+ _, err := os.Lstat(path)
+ if !errors.Is(err, fs.ErrNotExist) {
+ t.Errorf("%s: created, or Lstat failed: %v", path, err)
+ }
+}
+
+func requireOwner(t *testing.T, path string, uid, gid uint32) {
+ t.Helper()
+
+ info, err := os.Lstat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ stat, _ := info.Sys().(*syscall.Stat_t)
+ if stat.Uid != uid || stat.Gid != gid {
+ t.Errorf("%s: owner %d:%d, want %d:%d", path, stat.Uid, stat.Gid,
+ uid, gid)
+ }
+}
+
+func TestPrepareDataDirCreatesDataDir(t *testing.T) {
+ t.Parallel()
+
+ volume := t.TempDir()
+ dir := filepath.Join(volume, "a", reports)
+
+ err := reportbuf.PrepareDataDir(volume, dir, currentUser())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ requireMode(t, volume, fs.ModeDir|0o750)
+ requireMode(t, dir, fs.ModeDir|0o750)
+}
+
+// TestPrepareDataDirSetsModeOfExistingDataDir: a DATA_DIR already on
+// the host with another mode gets the mode too, not only a new one.
+func TestPrepareDataDirSetsModeOfExistingDataDir(t *testing.T) {
+ t.Parallel()
+
+ volume := t.TempDir()
+ dir := filepath.Join(volume, reports)
+
+ err := os.Mkdir(dir, 0o700)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = reportbuf.PrepareDataDir(volume, dir, currentUser())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ requireMode(t, dir, fs.ModeDir|0o750)
+}
+
+func TestPrepareDataDirTakesTheVolumeItself(t *testing.T) {
+ t.Parallel()
+
+ volume := t.TempDir()
+
+ err := reportbuf.PrepareDataDir(volume, volume, currentUser())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ requireMode(t, volume, fs.ModeDir|0o750)
+}
+
+// TestPrepareDataDirRefusesDataDirOutsideVolume covers a DATA_DIR that
+// is relative, outside the volume, or not written in full.
+func TestPrepareDataDirRefusesDataDirOutsideVolume(t *testing.T) {
+ t.Parallel()
+
+ volume := tempDirMode700(t)
+ for _, dir := range []string{
+ reports, volume + "/../new", volume + "/", volume + "//" + reports,
+ volume + "/./" + reports, volume + "/" + reports + "/..", volume + "x",
+ "/etc",
+ } {
+ err := reportbuf.PrepareDataDir(volume, dir, currentUser())
+ if !errors.Is(err, reportbuf.ErrDataDirOutsideVolume) {
+ t.Errorf("%q: error = %v, want ErrDataDirOutsideVolume", dir, err)
+ }
+ }
+
+ requireMissing(t, filepath.Join(filepath.Dir(volume), "new"))
+ requireMode(t, volume, fs.ModeDir|0o700)
+}
+
+// TestPrepareDataDirRefusesLinkOutOfVolume puts a symbolic link to a
+// directory outside the volume on the path to DATA_DIR, written as a
+// full path and as one that climbs out with '..', and as DATA_DIR
+// itself, where the mode would be set through it.
+func TestPrepareDataDirRefusesLinkOutOfVolume(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ climbsOut bool
+ link, dir string
+ }{
+ {"full path", false, "x", "x/reports"},
+ {"climbs out", true, "x", "x/reports"},
+ {"DATA_DIR itself", false, reports, reports},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ outside := tempDirMode700(t)
+ volume := t.TempDir()
+
+ climbOut, err := filepath.Rel(volume, outside)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ target := outside
+ if tc.climbsOut {
+ target = climbOut
+ }
+
+ err = os.Symlink(target, filepath.Join(volume, tc.link))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = reportbuf.PrepareDataDir(volume,
+ filepath.Join(volume, tc.dir), currentUser())
+ if err == nil {
+ t.Error("no error")
+ }
+
+ requireMissing(t, filepath.Join(outside, reports))
+ requireMode(t, outside, fs.ModeDir|0o700)
+ })
+ }
+}
+
+// TestPrepareDataDirRefusesDanglingLink: DATA_DIR is a symbolic link
+// to a name in the volume that does not exist, which is not created.
+func TestPrepareDataDirRefusesDanglingLink(t *testing.T) {
+ t.Parallel()
+
+ volume := t.TempDir()
+
+ err := os.Symlink("missing", filepath.Join(volume, reports))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
+ currentUser())
+ if err == nil {
+ t.Error("no error")
+ }
+
+ requireMissing(t, filepath.Join(volume, "missing"))
+}
+
+// TestPrepareDataDirTakesDirectoryNamedInLatin1: a host directory can
+// hold names that are not valid UTF-8, here "café" written in Latin-1.
+// A test not run as root can only check that PrepareDataDir goes into
+// such a directory and gives it, and what it holds, to the current
+// user.
+func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
+ t.Parallel()
+
+ volume := t.TempDir()
+ latin1 := filepath.Join(volume, "caf\xe9")
+
+ err := os.Mkdir(latin1, 0o700)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = os.WriteFile(filepath.Join(latin1, "f"), nil, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ owner := currentUser()
+
+ err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
+ owner)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ uid, _ := strconv.ParseUint(owner.Uid, 10, 32)
+ gid, _ := strconv.ParseUint(owner.Gid, 10, 32)
+
+ requireOwner(t, latin1, uint32(uid), uint32(gid))
+ requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
+}
+
+// TestPrepareDataDirGivesVolumeToOwner gives everything in the volume
+// to a uid and gid that own nothing, which only root can do. A
+// symbolic link in the volume to a directory outside it is given to
+// them itself; what it points to is left as it was.
+func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
+ t.Parallel()
+
+ if os.Geteuid() != 0 {
+ t.Skip("only root can give files to another uid")
+ }
+
+ outside := t.TempDir()
+ volume := t.TempDir()
+ old := filepath.Join(volume, "old")
+
+ err := os.WriteFile(filepath.Join(outside, "f"), nil, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = os.Mkdir(old, 0o700)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = os.WriteFile(filepath.Join(old, "f"), nil, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = os.Symlink(outside, filepath.Join(old, "link"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
+ &user.User{Uid: "4242", Gid: "4343"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, path := range []string{
+ volume, filepath.Join(volume, reports), old,
+ filepath.Join(old, "f"), filepath.Join(old, "link"),
+ } {
+ requireOwner(t, path, 4242, 4343)
+ }
+
+ requireOwner(t, outside, 0, 0)
+ requireOwner(t, filepath.Join(outside, "f"), 0, 0)
+}
diff --git a/bin/entrypoint.sh b/bin/entrypoint.sh
index 6f3bdf8..ee53bfe 100755
--- a/bin/entrypoint.sh
+++ b/bin/entrypoint.sh
@@ -63,26 +63,13 @@ done > /etc/nginx/trusted-proxies.conf
# netwatch-server keeps its report files in DATA_DIR, on the /data
# volume, which may be a host directory owned by root or by another
-# uid. Both are given to the netwatch user here, with the mode the
-# server gives a directory it creates, so the host directory needs no
-# preparing.
-#
-# chown and chmod, run as root, change whatever a symbolic link on the
-# path points to, anywhere in the container, and the netwatch user can
-# put one in /data. So the start stops unless readlink -f, which
-# follows every link on a path, gives /data and DATA_DIR back as they
-# are. It also writes a path in full, so a DATA_DIR with '.', '..' or
-# an extra '/' in it is refused too.
+# uid. Here, as root, netwatch-server prepare-data-dir creates DATA_DIR
+# and gives /data and everything in it to the netwatch user, so the
+# host directory needs no preparing. It stops the start, naming
+# DATA_DIR, unless DATA_DIR is /data or a path below it, and it acts on
+# nothing outside /data, whatever symbolic links it meets there.
export DATA_DIR="${DATA_DIR:-/data/reports}"
-mkdir -p "$DATA_DIR" || exit 1
-if [ "$(readlink -f /data)" != /data ] ||
- [ "$(readlink -f "$DATA_DIR")" != "$DATA_DIR" ]; then
- echo "entrypoint: DATA_DIR must be a full path with no '.', '..'," \
- "extra '/' or symbolic link on it or on /data, not '$DATA_DIR'" >&2
- exit 1
-fi
-chown -R netwatch:netwatch /data "$DATA_DIR" || exit 1
-chmod 750 /data "$DATA_DIR" || exit 1
+netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
# A stop signal is only noted here; the loop below acts on it.
stop_requested=""
--
2.54.0
From 91856fa170efb2b6ba43f13fb472ba52bc4d1690 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 01:41:04 +0200
Subject: [PATCH 08/62] Each target's row shows its result as soon as its check
ends (closes #91)
tick drew no row until the round's slowest check ended, up to 24
seconds at a 30-second interval since checks time out at 80% of it.
Each check now pushes its sample and redraws its row as it ends. When
the last check ends, every row is redrawn, so none still reads "paused"
after a pause and resume, and sorting, the summary, the health box and
offline detection run once. A check that ends while paused or after its
round is given up draws nothing, and the first round is still discarded
as a whole. The row is looked up when the check ends, as a pin click can
re-sort the rows mid-round.
Unit tests run tick on the mocked clock against a stand-in page.
Model: opus-5-5
---
README.md | 13 +--
TODO.md | 7 ++
src/main.js | 43 ++++++----
test/unit/main.test.js | 184 ++++++++++++++++++++++++++++++++++++++---
4 files changed, 214 insertions(+), 33 deletions(-)
diff --git a/README.md b/README.md
index d124263..a1cc51a 100644
--- a/README.md
+++ b/README.md
@@ -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
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` /
`updateSummary()` / `updateHealthBox()` handle incremental updates
-- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
- pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
- false outage)
+- **`tick()`**: Main loop — measures all hosts in parallel, pushing each host's
+ sample and redrawing its row as soon as its check ends, then redraws every
+ 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
### 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
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
-last round's checks if they are still waiting, and that round records nothing,
-so rounds never overlap. IPv4 only.
+last round's checks if they are still waiting, and that round records nothing
+more, so rounds never overlap. IPv4 only.
### Color coding
diff --git a/TODO.md b/TODO.md
index b4aee6d..76644bc 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,13 @@ latest run passes.
# 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: root no longer acts outside `/data` when it prepares `DATA_DIR`
(issue #80): `bin/entrypoint.sh` runs `netwatch-server prepare-data-dir`,
which refuses a `DATA_DIR` that is not `/data` or a path below it written in
diff --git a/src/main.js b/src/main.js
index 82f1dbd..df9fae6 100644
--- a/src/main.js
+++ b/src/main.js
@@ -263,7 +263,7 @@ class HostState {
}
}
-class AppState {
+export class AppState {
constructor(localHosts) {
this.wan = WAN_HOSTS.map(
(h) => new HostState(h, h.name === "datavi.be"),
@@ -1105,7 +1105,7 @@ function sortAndRebuildWAN(state) {
// --- Main Loop ---------------------------------------------------------------
-async function tick(state, signal, onOffline) {
+export async function tick(state, signal, onOffline) {
const ts = Date.now();
if (state.paused) {
@@ -1126,12 +1126,26 @@ async function tick(state, signal, onOffline) {
log.debug(`Tick #${state.tickCount + 1} started`);
- const results = await Promise.all(
- state.allHosts.map((h) => measureLatency(h.url, signal)),
+ // 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.
+ 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
- // 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;
state.tickCount++;
@@ -1142,12 +1156,9 @@ async function tick(state, signal, onOffline) {
return;
}
- state.allHosts.forEach((host, i) => {
- const r = results[i];
- host.pushSample(ts, r);
- updateHostRow(host, i);
- log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
- });
+ // Redraw every row: if the user paused and resumed during this round,
+ // rows whose check ended before the resume still read "paused"
+ state.allHosts.forEach((host, i) => updateHostRow(host, i));
// Sort after the first real check, then every 10 ticks thereafter
if (state.tickCount === 2 || state.tickCount % 10 === 1) {
@@ -1213,7 +1224,7 @@ function stopRecoveryProbe(state) {
// --- Pause / Resume ----------------------------------------------------------
-function greyOutUI(state) {
+export function greyOutUI(state) {
// Grey out all host rows
state.allHosts.forEach((host, i) => {
const latencyEl = document.querySelector(
@@ -1385,10 +1396,10 @@ async function init() {
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.
- // At a steady interval they never are, as they time out at 80% of it;
- // they can be when a round starts early, after an interval change or
- // when the recovery probe finds a target answering.
+ // 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
+ // it; they can be when a round starts early, after an interval change
+ // or when the recovery probe finds a target answering.
let roundChecks = new AbortController();
function doTick() {
roundChecks.abort();
diff --git a/test/unit/main.test.js b/test/unit/main.test.js
index c49ef63..38b4212 100644
--- a/test/unit/main.test.js
+++ b/test/unit/main.test.js
@@ -1,19 +1,70 @@
// 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.
-import { test } from "node:test";
+import { beforeEach, test } from "node:test";
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
-// panel in the page. There is no page here.
-globalThis.document = { getElementById: () => null };
+// There is no page here, so the tests stand in for it. The debug log looks
+// for its panel by id and finds none. Each element of a host's row that
+// 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
-// time, and replaces fetch with a target that answers after answerAfter
-// milliseconds of that clock, or never when answerAfter is Infinity. Both
-// are restored when the test ends.
-function mockTarget(t, answerAfter) {
+// time, and replaces fetch with targets that each answer after
+// answerAfter(url) milliseconds of that clock, or never when that is
+// Infinity. Both are restored when the test ends.
+function mockTargets(t, answerAfter) {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
t.mock.method(performance, "now", () => Date.now());
t.mock.method(
@@ -21,7 +72,9 @@ function mockTarget(t, answerAfter) {
"fetch",
(url, { signal }) =>
new Promise((resolve, reject) => {
- if (answerAfter !== Infinity) setTimeout(resolve, answerAfter);
+ if (answerAfter(url) !== Infinity) {
+ setTimeout(resolve, answerAfter(url));
+ }
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) => {
CONFIG.updateInterval = interval;
- mockTarget(t, slowAnswer);
+ mockTargets(t, () => slowAnswer);
const check = measureLatency("https://target.test");
t.mock.timers.tick(slowAnswer);
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) => {
CONFIG.updateInterval = interval;
- mockTarget(t, Infinity);
+ mockTargets(t, () => Infinity);
const check = measureLatency("https://target.test");
t.mock.timers.tick(timeout - 1);
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);
+ }
+});
--
2.54.0
From 2f0489e3a4e95fa95986994edc8ae3166a5bd3ae Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 01:53:02 +0200
Subject: [PATCH 09/62] Tap-target check expects a pin button per WAN host row
(closes #46)
The viewport harness's tap-target check required at least 10 visible
pin buttons while 26 render, so pin buttons missing from up to 16 rows
went unnoticed. It now expects one per WAN host row. The host row count
the harness gathers, which the app-rendered check also reads, counts
only the WAN host rows, since the local host rows have no pin button.
Each control's minimum is now worked out from the gathered facts.
TODO.md's harness entry no longer says every check guards itself: the
overflow, viewport-edge and clipped-text checks rely on app-rendered.
Model: opus-5-5
---
TODO.md | 13 ++++++++++---
test/viewport/README.md | 9 +++++----
test/viewport/checks.js | 27 ++++++++++++++-------------
test/viewport/facts.js | 4 +++-
4 files changed, 32 insertions(+), 21 deletions(-)
diff --git a/TODO.md b/TODO.md
index 76644bc..e34de1b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,11 @@ latest run passes.
# Completed Steps
+- 2026-10-03: the tap-target check in `make frontend-viewport-test` expects one
+ visible pin button per WAN host row (issue #46), where it expected at least 10
+ of the 26, so pin buttons missing from only some rows now fail it. The host
+ row count the harness gathers, which the `app-rendered` check also reads, now
+ counts only the WAN host rows: the local host rows have no pin button
- 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
@@ -237,9 +242,11 @@ latest run passes.
- 2026-08-09: automated responsive-layout harness
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
against the built `dist/`, viewport widths derived from the breakpoints in
- `src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). Every
- check carries a presence guard so none of them can pass against a page it is
- not actually measuring. Found two real layout defects, filed as
+ `src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). The
+ tap-target and host-row checks each fail when they measured nothing; the
+ overflow, viewport-edge and clipped-text checks have no such guard of their
+ own and rely on the `app-rendered` check, which fails the run when the app did
+ not render. Found two real layout defects, filed as
[#42](https://git.eeqj.de/sneak/netwatch/issues/42) and
[#43](https://git.eeqj.de/sneak/netwatch/issues/43)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
diff --git a/test/viewport/README.md b/test/viewport/README.md
index d37a688..9ee28a5 100644
--- a/test/viewport/README.md
+++ b/test/viewport/README.md
@@ -49,10 +49,11 @@ sizes straddling the breakpoint for the rotation case.
excluded: it is a design choice, not breakage.
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
touch viewports, _and_ each selector in the control list matched at least the
- number of visible elements it declares. The second half is what stops the
- check passing vacuously: with size alone, a renamed class would take its
- controls out of the measured set and the check would report "all 0 controls
- are at least 44x44" and pass. See below.
+ number of visible elements it declares: one of each single control, and one
+ pin button per WAN host row. The second half is what stops the check passing
+ vacuously: with size alone, a renamed class would take its controls out of the
+ measured set and the check would report "all 0 controls are at least 44x44"
+ and pass. See below.
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
Computed `flex-direction` _and_ the actual geometry are checked, and in the
narrow layout the info block and the sparkline must each occupy essentially
diff --git a/test/viewport/checks.js b/test/viewport/checks.js
index 14c7ebf..4f5648a 100644
--- a/test/viewport/checks.js
+++ b/test/viewport/checks.js
@@ -15,7 +15,8 @@ export const MIN_TAP_TARGET_PX = 44;
// The controls named in the definition of done, plus the pause button.
// Each carries the smallest number of *visible* instances the page has to
-// contain for the tap-target oracle to be measuring anything at all.
+// contain, worked out from the facts gathered from that page, for the
+// tap-target oracle to be measuring every control it should.
//
// Without those floors the check is inert: `undersized` is empty both when
// every control is large enough and when the selectors have gone stale and
@@ -24,13 +25,12 @@ export const MIN_TAP_TARGET_PX = 44;
// for all three singleton controls vanishing at once — so the floor is per
// selector, and one stale selector out of four fails the check.
export const INTERACTIVE_CONTROLS = [
- { selector: "#pause-btn", minCount: 1 },
- { selector: "#interval-select", minCount: 1 },
- // One per pinnable host row. `app-rendered` already requires at least
- // 10 host rows, so a count below that means the pin buttons stopped
- // being rendered per row rather than that there were fewer hosts.
- { selector: ".pin-btn", minCount: 10 },
- { selector: "#debug-toggle", minCount: 1 },
+ { selector: "#pause-btn", minCount: () => 1 },
+ { selector: "#interval-select", minCount: () => 1 },
+ // One per WAN host row, so pin buttons missing from even one row fail
+ // the check rather than only a drop below some fixed number.
+ { selector: ".pin-btn", minCount: (facts) => facts.wanRowCount },
+ { selector: "#debug-toggle", minCount: () => 1 },
];
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
@@ -105,8 +105,8 @@ export function evaluateChecks(facts, viewport, probes) {
// never rendered. Everything below is only meaningful if this holds.
check(
"app-rendered",
- facts.rowCount >= 10 && facts.numericLatencies >= 5,
- `${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`,
+ facts.wanRowCount >= 10 && facts.numericLatencies >= 5,
+ `${facts.wanRowCount} WAN host rows, ${facts.numericLatencies} showing a numeric latency`,
);
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
@@ -162,7 +162,8 @@ export function evaluateChecks(facts, viewport, probes) {
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
}
const missing = INTERACTIVE_CONTROLS.filter(
- (control) => (seen.get(control.selector) ?? 0) < control.minCount,
+ (control) =>
+ (seen.get(control.selector) ?? 0) < control.minCount(facts),
);
const undersized = facts.tapTargets.filter(
@@ -184,11 +185,11 @@ export function evaluateChecks(facts, viewport, probes) {
const detail = [];
if (missing.length > 0) {
detail.push(
- "oracle is not measuring the page: " +
+ "oracle is not measuring every control: " +
summarise(
missing,
(c) =>
- `${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
+ `${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount(facts)}`,
4,
),
);
diff --git a/test/viewport/facts.js b/test/viewport/facts.js
index f266e6c..5c124f8 100644
--- a/test/viewport/facts.js
+++ b/test/viewport/facts.js
@@ -173,7 +173,9 @@ export function collectLayoutFacts(options) {
clipped,
tapTargets,
rows,
- rowCount: document.querySelectorAll(".host-row").length,
+ // WAN host rows only: each has a pin button, and the tap-target
+ // check expects one per row. The local host rows have none.
+ wanRowCount: document.querySelectorAll("#wan-hosts .host-row").length,
numericLatencies: Array.from(
document.querySelectorAll(".latency-value"),
).filter((el) => /\d/.test(el.textContent)).length,
--
2.54.0
From 924527b744d4c9839dfe9431ad46edd029e278fc Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 01:58:07 +0200
Subject: [PATCH 10/62] Lint the frontend with eslint in its own Docker stage
(closes #47)
script/lint ran prettier --check, the same check script/fmt-check
runs, so the JavaScript had no linter. eslint now runs with its
recommended rules, set in eslint.config.js, in a new frontend-lint
stage of Dockerfile built from the pinned node image and the lockfile.
The frontend stage copies a file from it, as the builder stage does
from the Go lint stage, so the image cannot build unless eslint passed.
script/lint builds both lint stages with --no-cache and runs no linter
on the host; script/fmt-check keeps prettier on the host, and
script/frontend-check drops its lint step. The viewport harness fixes
the two kinds of finding eslint made. bootstrap wants node 22.13.0, as
eslint 10 does. Also covers item 2 of
https://git.eeqj.de/sneak/netwatch/issues/28.
Model: opus-5-5
---
Dockerfile | 27 +-
Makefile | 4 +-
README.md | 18 +-
TODO.md | 9 +
eslint.config.js | 27 ++
package.json | 3 +
script/bootstrap | 8 +-
script/frontend-check | 12 +-
script/frontend-lint | 7 +-
script/lint | 18 +-
test/viewport/harness.js | 10 +-
yarn.lock | 533 +++++++++++++++++++++++++++++++++++++++
12 files changed, 637 insertions(+), 39 deletions(-)
create mode 100644 eslint.config.js
diff --git a/Dockerfile b/Dockerfile
index 30ddf6b..66a7d5c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -59,20 +59,35 @@ RUN version="${VERSION:-$(git --git-dir=/git describe --tags --always)}"; \
esac; \
VERSION="$version" make build
+# Frontend lint stage — eslint over the JavaScript, as the lint stage
+# above lints the Go. The root make lint builds this stage alone too.
+# node:22-alpine as of 2026-02-22
+FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint
+WORKDIR /app
+COPY package.json yarn.lock ./
+RUN yarn install --frozen-lockfile
+COPY . .
+RUN script/frontend-lint
+
# Frontend stage
# node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend
WORKDIR /app
+
+# Force BuildKit to run the frontend-lint stage before proceeding, as
+# the builder stage does with the lint stage: without this no-op copy an
+# eslint failure would not gate the image.
+COPY --from=frontend-lint /app/yarn.lock /dev/null
+
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
RUN apk add --no-cache git make
COPY . .
-# make frontend-check is the frontend half of make check (test + lint +
-# fmt-check); its test step runs the unit tests, then the production
-# yarn build, so this both produces dist/ and gates the image on
-# lint/fmt-check/test regressions.
-# This node stage has neither Go nor Docker; the lint and builder stages
-# above gate the backend half.
+# make frontend-check runs the frontend tests and format check; its test
+# step runs the unit tests, then the production yarn build, so this both
+# produces dist/ and gates the image on test and formatting regressions.
+# This node stage has neither Go nor Docker; the frontend-lint, lint and
+# builder stages above gate the rest.
RUN make frontend-check
# Runtime stage
diff --git a/Makefile b/Makefile
index eb7bb43..211f8b8 100644
--- a/Makefile
+++ b/Makefile
@@ -30,8 +30,8 @@ fmt-check:
check:
@script/check
-# The frontend half of check, for Dockerfile's node build stage, which
-# has neither Go nor Docker. Use check everywhere else.
+# The frontend tests and format check, for Dockerfile's frontend stage,
+# which has neither Go nor Docker. Use check everywhere else.
frontend-check:
@script/frontend-check
diff --git a/README.md b/README.md
index a1cc51a..a9c33bb 100644
--- a/README.md
+++ b/README.md
@@ -42,25 +42,28 @@ halves, so the root `make check` fails if either one is broken. We provide:
as new as `backend/go.mod` asks for is installed, the Go modules, and gcc with
the C library headers unless gcc is installed, for the race detector in
`make test`), linking what it installs itself into `~/.local/bin`, which has
- to be on `PATH`. It installs no Go linter and not Docker: `make lint` runs the
- linter in Docker
+ to be on `PATH`. It installs no Go linter and not Docker: `make lint` runs
+ both linters in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run `script/frontend-test`, then the backend's Go tests, each
under its own 30-second timeout
-- `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by
- building the lint stage of `Dockerfile` without the cache
+- `script/lint` — run eslint, then golangci-lint, both in Docker, by building
+ the `frontend-lint` and `lint` stages of `Dockerfile` without the cache
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
- `script/check` — run test, lint, and fmt-check
- `script/frontend-test` — run the unit tests in `test/unit/` with Node's
built-in test runner, then the production build
-- `script/frontend-lint` — run prettier in check mode
+- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
+ runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
+ builds
- `script/frontend-fmt` — format everything prettier understands (writes)
- `script/frontend-fmt-check` — check prettier formatting (read-only)
-- `script/frontend-check` — the frontend half of `script/check`, for
- `Dockerfile`, whose node build stage has neither Go nor Docker
+- `script/frontend-check` — run `script/frontend-test` and
+ `script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has
+ neither Go nor Docker
- `script/frontend-viewport-test` — responsive-layout verification of the built
frontend in a containerised headless Chrome (see
[test/viewport/README.md](test/viewport/README.md)). Not part of
@@ -265,7 +268,6 @@ properties.
## TODO
- Add unit tests
-- Add eslint for JS linting (currently lint target runs prettier only)
- Add configurable host list (environment variable or config file)
- Add latency history export (CSV/JSON)
- Add notification/alert when status changes to DEGRADED
diff --git a/TODO.md b/TODO.md
index e34de1b..6445247 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,15 @@ latest run passes.
# Completed Steps
+- 2026-10-03: the frontend has a real linter (issue #47, and item 2 of issue
+ #28): `eslint` with its recommended rules, set in `eslint.config.js`, runs in
+ a new `frontend-lint` stage of `Dockerfile`, which the frontend stage waits
+ on, as the builder stage waits on the Go `lint` stage. `script/lint` builds
+ both stages without the cache and runs no linter on the host.
+ `script/frontend-lint` runs eslint where it used to repeat the prettier check
+ that `script/fmt-check` runs on the host, and `script/frontend-check`, run by
+ the frontend stage, is now the tests and the format check. `script/bootstrap`
+ wants node 22.13.0 or newer, as eslint 10 does
- 2026-10-03: the tap-target check in `make frontend-viewport-test` expects one
visible pin button per WAN host row (issue #46), where it expected at least 10
of the 26, so pin buttons missing from only some rows now fail it. The host
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..2aab933
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,27 @@
+// eslint's recommended rules over every JavaScript file in the repo.
+// script/frontend-lint runs it, in the frontend-lint stage of Dockerfile.
+import js from "@eslint/js";
+import globals from "globals";
+import { defineConfig } from "eslint/config";
+
+export default defineConfig([
+ js.configs.recommended,
+ {
+ // The page, and facts.js, which the viewport test runs inside it.
+ files: ["src/**/*.js", "test/viewport/facts.js"],
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ // vite.config.js defines these when it builds the page.
+ __COMMIT_HASH__: "readonly",
+ __COMMIT_FULL__: "readonly",
+ },
+ },
+ },
+ {
+ // Run by node: the tests, the viewport harness and these configs.
+ files: ["test/**/*.js", "*.js"],
+ ignores: ["test/viewport/facts.js"],
+ languageOptions: { globals: globals.node },
+ },
+]);
diff --git a/package.json b/package.json
index f4cfeaf..a268a99 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,11 @@
},
"license": "MIT",
"devDependencies": {
+ "@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.23",
+ "eslint": "^10.12.0",
+ "globals": "^17.13.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"puppeteer-core": "25.5.0",
diff --git a/script/bootstrap b/script/bootstrap
index abe1d50..7d06df7 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -24,9 +24,9 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07
NODE_VERSION="22.17.0"
# The oldest node the frontend's dependencies accept: the "engines"
-# field of puppeteer-core 25.5.0, the most demanding of them, asks for
-# 22.12.0 or newer, 2026-09-29. An older installed node is not used.
-NODE_MIN_VERSION="22.12.0"
+# field of eslint 10.12.0, the most demanding of them, asks for 22.13.0
+# or newer, 2026-10-03. An older installed node is not used.
+NODE_MIN_VERSION="22.13.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
@@ -270,7 +270,7 @@ main() {
if missing docker; then
echo "bootstrap: docker not found; make lint, and so make check" >&2
- echo " and the pre-commit hook, need it to run the Go linter" >&2
+ echo " and the pre-commit hook, need it to run the linters" >&2
fi
if [ -n "$path_hint" ] && [ -d "$BIN_DIR" ]; then
echo "bootstrap: add $BIN_DIR to the front of your PATH, e.g." >&2
diff --git a/script/frontend-check b/script/frontend-check
index 0819860..fad36b0 100755
--- a/script/frontend-check
+++ b/script/frontend-check
@@ -1,9 +1,10 @@
#!/bin/sh
-# script/frontend-check: run the frontend half of the checks only (test,
-# lint, fmt-check). This exists for the frontend stage of Dockerfile, a
-# node image with neither Go nor Docker; the Dockerfile's lint and
-# backend build stages gate the backend half. Everywhere else, use
-# script/check, which covers the whole repo. Must not modify any files.
+# script/frontend-check: run the frontend tests and format check only.
+# This exists for the frontend stage of Dockerfile, a node image with
+# neither Go nor Docker; the Dockerfile's frontend-lint stage runs the
+# frontend linter, and its lint and builder stages gate the backend.
+# Everywhere else, use script/check, which covers the whole repo. Must
+# not modify any files.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -11,7 +12,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/frontend-test"
- "$ROOT/script/frontend-lint"
"$ROOT/script/frontend-fmt-check"
}
diff --git a/script/frontend-lint b/script/frontend-lint
index 12b5b29..caa01d4 100755
--- a/script/frontend-lint
+++ b/script/frontend-lint
@@ -1,12 +1,15 @@
#!/bin/sh
-# script/frontend-lint: run the frontend linter (prettier in check mode).
+# script/frontend-lint: run eslint over the frontend. This runs inside
+# the frontend-lint stage of Dockerfile, the digest-pinned node image
+# with the packages from yarn.lock. From a checkout, run `make lint`,
+# which builds that stage.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
- yarn prettier --check .
+ yarn eslint .
}
main "$@"
diff --git a/script/lint b/script/lint
index 1b3783d..d0192a2 100755
--- a/script/lint
+++ b/script/lint
@@ -1,19 +1,21 @@
#!/bin/sh
-# script/lint: lint the whole repo: prettier over the frontend, then the
-# Go linter over backend/.
+# script/lint: lint the whole repo: eslint over the frontend, then the Go
+# linter over backend/.
#
-# The Go linter runs only in Docker: this builds the lint stage of
-# Dockerfile, the digest-pinned golangci-lint image, which runs the
-# backend's fmt-check and lint targets. --no-cache makes the linter
-# really run every time rather than reuse an earlier result, and the
-# stage is built for its checks alone, so no image is kept.
+# No linter runs on the host: this builds the frontend-lint and lint
+# stages of Dockerfile, the digest-pinned node and golangci-lint images.
+# The first runs eslint; the second runs the backend's fmt-check and
+# lint targets. --no-cache makes each linter really run every time
+# rather than reuse an earlier result, and each stage is built for its
+# checks alone, so no image is kept.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
- "$ROOT/script/frontend-lint"
+ timeout 300 docker build --no-cache --target frontend-lint \
+ --output type=cacheonly .
timeout 300 docker build --no-cache --target lint \
--output type=cacheonly .
}
diff --git a/test/viewport/harness.js b/test/viewport/harness.js
index 52a4483..4dd95fa 100644
--- a/test/viewport/harness.js
+++ b/test/viewport/harness.js
@@ -16,6 +16,10 @@ import { collectLayoutFacts } from "./facts.js";
import { evaluateChecks, INTERACTIVE_SELECTORS } from "./checks.js";
import { deriveViewports } from "./viewports.js";
+// page.waitForFunction and page.evaluate run the functions given them in
+// the page, where these are defined.
+/* global document, requestAnimationFrame */
+
function required(name) {
const value = process.env[name];
if (!value) {
@@ -62,7 +66,6 @@ function stableHash(text) {
async function connectBrowser() {
const deadline = Date.now() + BROWSER_TIMEOUT_MS;
- let lastError;
for (;;) {
try {
const response = await fetch(`${CDP_URL}/json/version`);
@@ -77,9 +80,10 @@ async function connectBrowser() {
});
return { browser, version: info.Browser };
} catch (error) {
- lastError = error;
if (Date.now() > deadline) {
- throw new Error(`browser never came up: ${lastError}`);
+ throw new Error(`browser never came up: ${error}`, {
+ cause: error,
+ });
}
await sleep(250);
}
diff --git a/yarn.lock b/yarn.lock
index 980d6a4..79b5221 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,6 +2,24 @@
# yarn lockfile v1
+"@cacheable/memory@^2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@cacheable/memory/-/memory-2.2.0.tgz#72aeb8b051f6d597d10ac8595b94ad8302bd2239"
+ integrity sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==
+ dependencies:
+ "@cacheable/utils" "^2.5.0"
+ "@keyv/bigmap" "^1.3.1"
+ hookified "^1.15.1"
+ keyv "^5.6.0"
+
+"@cacheable/utils@^2.5.0":
+ version "2.5.0"
+ resolved "https://registry.yarnpkg.com/@cacheable/utils/-/utils-2.5.0.tgz#534c91113aa48fe43baedb169550b6ee070ef303"
+ integrity sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==
+ dependencies:
+ hashery "^1.5.1"
+ keyv "^5.6.0"
+
"@emnapi/core@^1.7.1":
version "1.8.1"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
@@ -154,6 +172,90 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
+"@eslint-community/eslint-utils@^4.8.0":
+ version "4.10.1"
+ resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
+ integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
+ dependencies:
+ eslint-visitor-keys "^3.4.3"
+
+"@eslint-community/regexpp@^4.12.2":
+ version "4.12.2"
+ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
+ integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
+
+"@eslint/config-array@^0.23.5":
+ version "0.23.5"
+ resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
+ integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
+ dependencies:
+ "@eslint/object-schema" "^3.0.5"
+ debug "^4.3.1"
+ minimatch "^10.2.4"
+
+"@eslint/config-helpers@^0.7.0":
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
+ integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
+ dependencies:
+ "@eslint/core" "^1.2.1"
+
+"@eslint/core@^1.2.1":
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
+ integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
+ dependencies:
+ "@types/json-schema" "^7.0.15"
+
+"@eslint/js@^10.0.1":
+ version "10.0.1"
+ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
+ integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
+
+"@eslint/object-schema@^3.0.5":
+ version "3.0.5"
+ resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
+ integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
+
+"@eslint/plugin-kit@^0.7.3":
+ version "0.7.3"
+ resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz#cc7268cc36405b331ef92db1bc37971f21d66fe1"
+ integrity sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==
+ dependencies:
+ "@eslint/core" "^1.2.1"
+ levn "^0.4.1"
+
+"@humanfs/core@^0.19.2":
+ version "0.19.2"
+ resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
+ integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
+ dependencies:
+ "@humanfs/types" "^0.15.0"
+
+"@humanfs/node@^0.16.6":
+ version "0.16.8"
+ resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
+ integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
+ dependencies:
+ "@humanfs/core" "^0.19.2"
+ "@humanfs/types" "^0.15.0"
+ "@humanwhocodes/retry" "^0.4.0"
+
+"@humanfs/types@^0.15.0":
+ version "0.15.0"
+ resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
+ integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
+
+"@humanwhocodes/module-importer@^1.0.1":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
+ integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
+
+"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
+ version "0.4.3"
+ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
+ integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
+
"@jridgewell/gen-mapping@^0.3.5":
version "0.3.13"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
@@ -188,6 +290,19 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
+"@keyv/bigmap@^1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@keyv/bigmap/-/bigmap-1.3.1.tgz#fc82fa83947e7ff68c6798d08907db842771ef2c"
+ integrity sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==
+ dependencies:
+ hashery "^1.4.0"
+ hookified "^1.15.0"
+
+"@keyv/serialize@^1.1.1":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
+ integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
+
"@napi-rs/wasm-runtime@^1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2"
@@ -444,11 +559,46 @@
dependencies:
tslib "^2.4.0"
+"@types/esrecurse@^4.3.1":
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec"
+ integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==
+
"@types/estree@1.0.8":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
+"@types/estree@^1.0.6", "@types/estree@^1.0.8":
+ version "1.0.9"
+ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
+ integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
+
+"@types/json-schema@^7.0.15":
+ version "7.0.15"
+ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
+ integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
+
+acorn-jsx@^5.3.2:
+ version "5.3.2"
+ resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
+ integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
+
+acorn@^8.16.0:
+ version "8.18.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
+ integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
+
+ajv@^6.14.0:
+ version "6.15.0"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
+ integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
+ dependencies:
+ fast-deep-equal "^3.1.1"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.4.1"
+ uri-js "^4.2.2"
+
ansi-regex@^6.2.2:
version "6.2.2"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
@@ -470,11 +620,23 @@ autoprefixer@^10.4.23:
picocolors "^1.1.1"
postcss-value-parser "^4.2.0"
+balanced-match@^4.0.2:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
+ integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
+
baseline-browser-mapping@^2.9.0:
version "2.9.19"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488"
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
+brace-expansion@^5.0.8:
+ version "5.0.12"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec"
+ integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==
+ dependencies:
+ balanced-match "^4.0.2"
+
browserslist@^4.28.1:
version "4.28.1"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95"
@@ -486,6 +648,17 @@ browserslist@^4.28.1:
node-releases "^2.0.27"
update-browserslist-db "^1.2.0"
+cacheable@^2.5.0:
+ version "2.5.0"
+ resolved "https://registry.yarnpkg.com/cacheable/-/cacheable-2.5.0.tgz#d142d41043e5a865f6053cc70ef4e3ad068bd5ce"
+ integrity sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==
+ dependencies:
+ "@cacheable/memory" "^2.2.0"
+ "@cacheable/utils" "^2.5.0"
+ hookified "^1.15.0"
+ keyv "^5.6.0"
+ qified "^0.10.1"
+
caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760:
version "1.0.30001766"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a"
@@ -508,6 +681,27 @@ cliui@^9.0.1:
strip-ansi "^7.1.0"
wrap-ansi "^9.0.0"
+cross-spawn@^7.0.6:
+ version "7.0.6"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
+ integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
+ dependencies:
+ path-key "^3.1.0"
+ shebang-command "^2.0.0"
+ which "^2.0.1"
+
+debug@^4.3.1, debug@^4.3.2:
+ version "4.4.3"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
+ integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
+ dependencies:
+ ms "^2.1.3"
+
+deep-is@^0.1.3:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
+ integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
+
detect-libc@^2.0.3:
version "2.1.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
@@ -573,11 +767,149 @@ escalade@^3.1.1, escalade@^3.2.0:
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
+escape-string-regexp@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+ integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+eslint-scope@^9.1.2:
+ version "9.1.2"
+ resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802"
+ integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==
+ dependencies:
+ "@types/esrecurse" "^4.3.1"
+ "@types/estree" "^1.0.8"
+ esrecurse "^4.3.0"
+ estraverse "^5.2.0"
+
+eslint-visitor-keys@^3.4.3:
+ version "3.4.3"
+ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
+ integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
+
+eslint-visitor-keys@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
+ integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
+
+eslint@^10.12.0:
+ version "10.12.0"
+ resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.12.0.tgz#4f2f7e679bdce9d71438c2900f2c8d4d297332b0"
+ integrity sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==
+ dependencies:
+ "@eslint-community/eslint-utils" "^4.8.0"
+ "@eslint-community/regexpp" "^4.12.2"
+ "@eslint/config-array" "^0.23.5"
+ "@eslint/config-helpers" "^0.7.0"
+ "@eslint/core" "^1.2.1"
+ "@eslint/plugin-kit" "^0.7.3"
+ "@humanfs/node" "^0.16.6"
+ "@humanwhocodes/module-importer" "^1.0.1"
+ "@humanwhocodes/retry" "^0.4.2"
+ "@types/estree" "^1.0.6"
+ ajv "^6.14.0"
+ cross-spawn "^7.0.6"
+ debug "^4.3.2"
+ escape-string-regexp "^4.0.0"
+ eslint-scope "^9.1.2"
+ eslint-visitor-keys "^5.0.1"
+ espree "^11.2.0"
+ esquery "^1.7.0"
+ esutils "^2.0.2"
+ fast-deep-equal "^3.1.3"
+ file-entry-cache "11.1.5 || >11.1.6 <12"
+ find-up "^5.0.0"
+ glob-parent "^6.0.2"
+ ignore "^5.2.0"
+ imurmurhash "^0.1.4"
+ is-glob "^4.0.0"
+ json-stable-stringify-without-jsonify "^1.0.1"
+ minimatch "^10.2.5"
+ natural-compare "^1.4.0"
+ optionator "^0.9.3"
+
+espree@^11.2.0:
+ version "11.2.0"
+ resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
+ integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
+ dependencies:
+ acorn "^8.16.0"
+ acorn-jsx "^5.3.2"
+ eslint-visitor-keys "^5.0.1"
+
+esquery@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
+ integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
+ dependencies:
+ estraverse "^5.1.0"
+
+esrecurse@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
+ integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
+ dependencies:
+ estraverse "^5.2.0"
+
+estraverse@^5.1.0, estraverse@^5.2.0:
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
+ integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
+
+esutils@^2.0.2:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
+ integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
+
+fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+ integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
+fast-json-stable-stringify@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+ integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fast-levenshtein@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+ integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
+
fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
+"file-entry-cache@11.1.5 || >11.1.6 <12":
+ version "11.1.5"
+ resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz#c8210eb055de63e68685ccfb6a017e386d4577d0"
+ integrity sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==
+ dependencies:
+ flat-cache "^6.1.23"
+
+find-up@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
+ integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
+ dependencies:
+ locate-path "^6.0.0"
+ path-exists "^4.0.0"
+
+flat-cache@^6.1.23:
+ version "6.1.23"
+ resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-6.1.23.tgz#735dc888c271868d7301b3bbb8edba5028afb61d"
+ integrity sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==
+ dependencies:
+ cacheable "^2.5.0"
+ flatted "^3.4.2"
+ hookified "^1.15.0"
+
+flatted@^3.4.2:
+ version "3.4.4"
+ resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
+ integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
+
fraction.js@^5.3.4:
version "5.3.4"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a"
@@ -598,16 +930,97 @@ get-east-asian-width@^1.0.0, get-east-asian-width@^1.5.0:
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9"
integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==
+glob-parent@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
+ integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+ dependencies:
+ is-glob "^4.0.3"
+
+globals@^17.13.0:
+ version "17.13.0"
+ resolved "https://registry.yarnpkg.com/globals/-/globals-17.13.0.tgz#ab2134b5ededbf2fbe2831dd0acc36f4f1ddeac5"
+ integrity sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==
+
graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+hashery@^1.4.0, hashery@^1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/hashery/-/hashery-1.5.1.tgz#4ba82ad54911ac617467870845d57a9fe508a400"
+ integrity sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==
+ dependencies:
+ hookified "^1.15.0"
+
+hookified@^1.15.0, hookified@^1.15.1:
+ version "1.15.1"
+ resolved "https://registry.yarnpkg.com/hookified/-/hookified-1.15.1.tgz#b1fafeaa5489cdc29cb85546a8f837ed4ffbbcb6"
+ integrity sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==
+
+hookified@^2.1.1:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/hookified/-/hookified-2.2.0.tgz#1d024ac1668973dd5bcc4a96ab9ccdb7639ef8d4"
+ integrity sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==
+
+ignore@^5.2.0:
+ version "5.3.2"
+ resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
+ integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
+
+imurmurhash@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+ integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
+
+is-extglob@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+ integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-glob@^4.0.0, is-glob@^4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+ integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+ dependencies:
+ is-extglob "^2.1.1"
+
+isexe@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+ integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
+
jiti@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92"
integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
+json-schema-traverse@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+ integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+json-stable-stringify-without-jsonify@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+ integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
+
+keyv@^5.6.0:
+ version "5.6.0"
+ resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.6.0.tgz#03044074c6b4d072d0a62c7b9fa649537baf0105"
+ integrity sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==
+ dependencies:
+ "@keyv/serialize" "^1.1.1"
+
+levn@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
+ integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
+ dependencies:
+ prelude-ls "^1.2.1"
+ type-check "~0.4.0"
+
lightningcss-android-arm64@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz#6966b7024d39c94994008b548b71ab360eb3a307"
@@ -682,6 +1095,13 @@ lightningcss@1.30.2:
lightningcss-win32-arm64-msvc "1.30.2"
lightningcss-win32-x64-msvc "1.30.2"
+locate-path@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
+ integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
+ dependencies:
+ p-locate "^5.0.0"
+
magic-string@^0.30.21:
version "0.30.21"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
@@ -689,6 +1109,13 @@ magic-string@^0.30.21:
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
+minimatch@^10.2.4, minimatch@^10.2.5:
+ version "10.2.6"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
+ integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
+ dependencies:
+ brace-expansion "^5.0.8"
+
mitt@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
@@ -699,16 +1126,62 @@ modern-tar@^0.7.6:
resolved "https://registry.yarnpkg.com/modern-tar/-/modern-tar-0.7.7.tgz#ca71d79603630076b10733b0751ccab284bbc1ef"
integrity sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==
+ms@^2.1.3:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+natural-compare@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
+ integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
+
node-releases@^2.0.27:
version "2.0.27"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
+optionator@^0.9.3:
+ version "0.9.4"
+ resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
+ integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
+ dependencies:
+ deep-is "^0.1.3"
+ fast-levenshtein "^2.0.6"
+ levn "^0.4.1"
+ prelude-ls "^1.2.1"
+ type-check "^0.4.0"
+ word-wrap "^1.2.5"
+
+p-limit@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
+ integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
+ dependencies:
+ yocto-queue "^0.1.0"
+
+p-locate@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
+ integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
+ dependencies:
+ p-limit "^3.0.2"
+
+path-exists@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+ integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-key@^3.1.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+ integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@@ -733,11 +1206,21 @@ postcss@^8.5.6:
picocolors "^1.1.1"
source-map-js "^1.2.1"
+prelude-ls@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
+ integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
+
prettier@^3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
+punycode@^2.1.0:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+ integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
+
puppeteer-core@25.5.0:
version "25.5.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-25.5.0.tgz#a41b14d582056b998e0bc3561ac47c7615d38a53"
@@ -750,6 +1233,13 @@ puppeteer-core@25.5.0:
webdriver-bidi-protocol "0.4.2"
ws "^8.21.1"
+qified@^0.10.1:
+ version "0.10.1"
+ resolved "https://registry.yarnpkg.com/qified/-/qified-0.10.1.tgz#0640bf21bbe6ca540db290ad8f5fde4d870b8bda"
+ integrity sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==
+ dependencies:
+ hookified "^2.1.1"
+
rollup@^4.43.0:
version "4.57.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"
@@ -784,6 +1274,18 @@ rollup@^4.43.0:
"@rollup/rollup-win32-x64-msvc" "4.57.0"
fsevents "~2.3.2"
+shebang-command@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+ integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+ dependencies:
+ shebang-regex "^3.0.0"
+
+shebang-regex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+ integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
@@ -836,6 +1338,13 @@ tslib@^2.4.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+type-check@^0.4.0, type-check@~0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
+ integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
+ dependencies:
+ prelude-ls "^1.2.1"
+
typed-query-selector@^2.12.2:
version "2.12.2"
resolved "https://registry.yarnpkg.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz#65e2462ac6b0aecfae1bfac1a4f3027070dbabaa"
@@ -849,6 +1358,13 @@ update-browserslist-db@^1.2.0:
escalade "^3.2.0"
picocolors "^1.1.1"
+uri-js@^4.2.2:
+ version "4.4.1"
+ resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
+ integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
+ dependencies:
+ punycode "^2.1.0"
+
vite@^7.3.1:
version "7.3.1"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507"
@@ -868,6 +1384,18 @@ webdriver-bidi-protocol@0.4.2:
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz#f51bb71c2606e90e3d5727607c728b25d617b58b"
integrity sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==
+which@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+ integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+ dependencies:
+ isexe "^2.0.0"
+
+word-wrap@^1.2.5:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
+ integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
+
wrap-ansi@^9.0.0:
version "9.0.2"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
@@ -904,6 +1432,11 @@ yargs@^18.0.0:
y18n "^5.0.5"
yargs-parser "^22.0.0"
+yocto-queue@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
+ integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+
zod@^3.24.1:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
--
2.54.0
From 82bd142429c67642edf6812185c28235e16073f5 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 03:11:43 +0200
Subject: [PATCH 11/62] Log fx through slog, snake_case health check keys
(closes #27)
fx wrote its own steps of starting and stopping as plain text to
stderr. It now logs them with its slog event logger through the
backend's logger, so off a terminal every line the backend's own
logger and fx write is JSON. A malformed config file now makes
config.New return the error instead of panicking. A test runs the
server as a child process and checks that all it writes is JSON, on
a normal start and stop and with such a config file. The backend
logs its name, version and architecture once at start.
The health check's uptime keys are now uptime_seconds and
uptime_human; its type and method take the names
GO_HTTP_SERVER_CONVENTIONS.md gives.
Rules suppressed: revive and tagliatelle on HealthcheckResponse, whose name and keys come from the conventions; gosec where the test starts its own binary.
Model: opus-5-5
---
TODO.md | 12 +
backend/cmd/netwatch-server/main.go | 14 +-
backend/cmd/netwatch-server/main_test.go | 227 ++++++++++++++++++
backend/internal/config/config.go | 7 +-
backend/internal/handlers/healthcheck.go | 2 +-
backend/internal/handlers/healthcheck_test.go | 16 +-
backend/internal/healthcheck/healthcheck.go | 19 +-
7 files changed, 276 insertions(+), 21 deletions(-)
create mode 100644 backend/cmd/netwatch-server/main_test.go
diff --git a/TODO.md b/TODO.md
index 6445247..7f13ff4 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,18 @@ latest run passes.
# Completed Steps
+- 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
+ every line the backend's own logger and fx write is JSON, where fx used to
+ write plain text to stderr. A config file that is found but cannot be read now
+ stops the start with its error, logged as JSON like a bad setting, where it
+ used to end in a Go panic. A test runs the server as a child process and
+ checks both. The backend logs its name, version and architecture once at
+ start. The health check's uptime keys are now `uptime_seconds` and
+ `uptime_human`; its path, content type, `"status":"ok"` and 200 are unchanged.
+ `SENTRY_DSN`, `METRICS_USERNAME` and `METRICS_PASSWORD` are still read and
+ still unused, and left out of `backend/README.md`, until issues #94 and #95
+ wire them up
- 2026-10-03: the frontend has a real linter (issue #47, and item 2 of issue
#28): `eslint` with its recommended rules, set in `eslint.config.js`, runs in
a new `frontend-lint` stage of `Dockerfile`, which the frontend stage waits
diff --git a/backend/cmd/netwatch-server/main.go b/backend/cmd/netwatch-server/main.go
index 0136dae..d9f3c17 100644
--- a/backend/cmd/netwatch-server/main.go
+++ b/backend/cmd/netwatch-server/main.go
@@ -16,6 +16,7 @@ import (
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
+ "go.uber.org/fx/fxevent"
)
//nolint:gochecknoglobals // set via ldflags at build time
@@ -62,6 +63,12 @@ func main() {
globals.Version = Version
fx.New(
+ // fx logs each step of starting and stopping through the
+ // server's own logger, so off a terminal those lines are
+ // JSON like every other line.
+ fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
+ return &fxevent.SlogLogger{Logger: log.Get()}
+ }),
fx.Provide(
config.New,
globals.New,
@@ -72,6 +79,11 @@ func main() {
reportbuf.New,
server.New,
),
- fx.Invoke(func(*server.Server) {}),
+ fx.Invoke(
+ // First, so the name and version are logged even when
+ // a setting stops the start.
+ func(log *logger.Logger) { log.Identify() },
+ func(*server.Server) {},
+ ),
).Run()
}
diff --git a/backend/cmd/netwatch-server/main_test.go b/backend/cmd/netwatch-server/main_test.go
new file mode 100644
index 0000000..964e867
--- /dev/null
+++ b/backend/cmd/netwatch-server/main_test.go
@@ -0,0 +1,227 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+)
+
+// The tests run main() in a child process, this test binary started
+// again with runMainEnv set, because main() can exit its process and
+// takes its settings from the environment.
+const runMainEnv = "NETWATCH_SERVER_RUN_MAIN"
+
+// childTimeout bounds each child's whole run; it is killed after it.
+const childTimeout = 10 * time.Second
+
+func TestMain(m *testing.M) {
+ if os.Getenv(runMainEnv) != "" {
+ // A SIGTERM that comes before fx catches it is dropped, not fatal.
+ signal.Notify(make(chan os.Signal, 1), syscall.SIGTERM)
+ main()
+
+ return
+ }
+
+ os.Exit(m.Run())
+}
+
+// TestOutputIsJSON: off a terminal, every line the server writes from
+// start to stop is JSON, fx's own lines included.
+func TestOutputIsJSON(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
+ defer cancel()
+
+ port := freePort(ctx, t)
+ child, stdout, stderr := startServer(ctx, t, t.TempDir(), port)
+
+ waitForHealthcheck(ctx, t, port)
+
+ // The child drops a SIGTERM that comes before fx catches it (see
+ // TestMain), so send one every 100ms until the test ends. ctx
+ // bounds the wait: when it ends, the child is killed.
+ stop := make(chan struct{})
+ defer close(stop)
+
+ go func() {
+ for {
+ _ = child.Process.Signal(syscall.SIGTERM)
+
+ select {
+ case <-stop:
+ return
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ }()
+
+ err := child.Wait()
+ if err != nil {
+ t.Fatalf("server exit = %v, want success", err)
+ }
+
+ requireJSONLines(t, stdout, stderr)
+
+ if !strings.Contains(stdout.String(), `"msg":"starting"`) {
+ t.Fatalf("no startup line in stdout:\n%s", stdout)
+ }
+}
+
+// TestMalformedConfigFileStopsTheStart: a config file the server finds
+// but cannot read stops the start, and the error is logged as JSON.
+func TestMalformedConfigFileStopsTheStart(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
+ defer cancel()
+
+ home := t.TempDir()
+ dir := filepath.Join(home, ".config", "netwatch-server")
+
+ err := os.MkdirAll(dir, 0o750)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = os.WriteFile(filepath.Join(dir, "netwatch-server.yaml"),
+ []byte("PORT: [8080\n"), 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ child, stdout, stderr := startServer(ctx, t, home, freePort(ctx, t))
+
+ err = child.Wait()
+ if child.ProcessState.ExitCode() != 1 {
+ t.Fatalf("server exit = %v, want exit status 1", err)
+ }
+
+ requireJSONLines(t, stdout, stderr)
+
+ if !strings.Contains(stdout.String(), "netwatch-server.yaml") {
+ t.Fatalf("no error naming the config file in stdout:\n%s", stdout)
+ }
+}
+
+// startServer runs main() in a child process listening on
+// 127.0.0.1:port, with home as its HOME and working directory and its
+// data directory in home, so it touches nothing outside home. Its
+// stdout and stderr go to the two buffers returned, which hold all of
+// it once child.Wait returns. The child is killed when ctx ends, and
+// killed and reaped when the test ends if nothing waited for it.
+func startServer(
+ ctx context.Context,
+ t *testing.T,
+ home, port string,
+) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) {
+ t.Helper()
+
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var stdout, stderr bytes.Buffer
+
+ child := exec.CommandContext(ctx, self) //nolint:gosec // this test binary
+ child.Dir = home
+ child.Env = append(os.Environ(),
+ runMainEnv+"=1",
+ "HOME="+home,
+ "DATA_DIR="+filepath.Join(home, "data"),
+ "BIND_ADDRESS=127.0.0.1",
+ "PORT="+port,
+ )
+ child.Stdout = &stdout
+ child.Stderr = &stderr
+
+ err = child.Start()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Cleanup(func() {
+ if child.ProcessState == nil {
+ _ = child.Process.Kill()
+ _ = child.Wait()
+ }
+ })
+
+ return child, &stdout, &stderr
+}
+
+// freePort returns a TCP port on 127.0.0.1 that was free a moment ago.
+func freePort(ctx context.Context, t *testing.T) string {
+ t.Helper()
+
+ var lc net.ListenConfig
+
+ l, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _ = l.Close()
+
+ _, port, err := net.SplitHostPort(l.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return port
+}
+
+// waitForHealthcheck returns once the health check on port answers 200.
+func waitForHealthcheck(ctx context.Context, t *testing.T, port string) {
+ t.Helper()
+
+ url := "http://127.0.0.1:" + port + "/.well-known/healthcheck"
+
+ for {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err == nil {
+ _ = resp.Body.Close()
+
+ if resp.StatusCode == http.StatusOK {
+ return
+ }
+ }
+
+ select {
+ case <-ctx.Done():
+ t.Fatalf("health check never answered: %v", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+ }
+}
+
+// requireJSONLines fails the test on each line of outs that is not
+// JSON.
+func requireJSONLines(t *testing.T, outs ...*bytes.Buffer) {
+ t.Helper()
+
+ for _, out := range outs {
+ for line := range strings.Lines(out.String()) {
+ if !json.Valid([]byte(line)) {
+ t.Errorf("line is not JSON: %s", line)
+ }
+ }
+ }
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index a258978..0abc49b 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -73,7 +73,8 @@ type Config struct {
// New loads configuration from env, .env files, and config
// files, returning a fully resolved Config. It fails, with an error
-// naming the setting, on a value the server cannot use.
+// naming the setting, on a value the server cannot use, and on a
+// config file it finds but cannot read.
func New(
_ fx.Lifecycle,
params Params,
@@ -106,8 +107,8 @@ func New(
if err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, ¬Found) {
- log.Error("config file malformed", "error", err)
- panic(err)
+ return nil, fmt.Errorf("config file %s: %w",
+ viper.ConfigFileUsed(), err)
}
}
diff --git a/backend/internal/handlers/healthcheck.go b/backend/internal/handlers/healthcheck.go
index d71e8bf..41cd943 100644
--- a/backend/internal/handlers/healthcheck.go
+++ b/backend/internal/handlers/healthcheck.go
@@ -6,6 +6,6 @@ import "net/http"
// endpoint.
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- s.respondJSON(w, r, s.hc.Check(), http.StatusOK)
+ s.respondJSON(w, r, s.hc.Healthcheck(), http.StatusOK)
}
}
diff --git a/backend/internal/handlers/healthcheck_test.go b/backend/internal/handlers/healthcheck_test.go
index dfbfa31..3348ab6 100644
--- a/backend/internal/handlers/healthcheck_test.go
+++ b/backend/internal/handlers/healthcheck_test.go
@@ -50,8 +50,8 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
// TestHandleHealthCheck checks the health check's answer: 200, a JSON
// content type, and a JSON object with exactly the fields of
-// healthcheck.Response, carrying this server's name and version and
-// an uptime counted from its start.
+// healthcheck.HealthcheckResponse, carrying this server's name and
+// version and an uptime counted from its start.
func TestHandleHealthCheck(t *testing.T) {
t.Parallel()
@@ -82,7 +82,7 @@ func TestHandleHealthCheck(t *testing.T) {
}
fields := []string{
- "appname", "now", "status", "uptimeHuman", "uptimeSeconds", "version",
+ "appname", "now", "status", "uptime_human", "uptime_seconds", "version",
}
if got := slices.Sorted(maps.Keys(body)); !slices.Equal(got, fields) {
t.Fatalf("fields = %v, want %v", got, fields)
@@ -104,17 +104,17 @@ func TestHandleHealthCheck(t *testing.T) {
}
// Started just now, so the uptime is well under a minute.
- human, _ := body["uptimeHuman"].(string)
+ human, _ := body["uptime_human"].(string)
uptime, err := time.ParseDuration(human)
if err != nil || uptime > time.Minute {
- t.Errorf("uptimeHuman = %q, want a duration under a minute (%v)",
+ t.Errorf("uptime_human = %q, want a duration under a minute (%v)",
human, err)
}
- seconds, ok := body["uptimeSeconds"].(float64)
+ seconds, ok := body["uptime_seconds"].(float64)
if !ok || seconds < 0 || seconds > time.Minute.Seconds() {
- t.Errorf("uptimeSeconds = %v, want a number of seconds under a minute",
- body["uptimeSeconds"])
+ t.Errorf("uptime_seconds = %v, want a number of seconds under a minute",
+ body["uptime_seconds"])
}
}
diff --git a/backend/internal/healthcheck/healthcheck.go b/backend/internal/healthcheck/healthcheck.go
index 4540abd..f4b78e1 100644
--- a/backend/internal/healthcheck/healthcheck.go
+++ b/backend/internal/healthcheck/healthcheck.go
@@ -30,14 +30,17 @@ type Healthcheck struct {
params *Params
}
-// Response is the JSON payload returned by the health check
-// endpoint.
-type Response struct {
+// HealthcheckResponse is the JSON payload returned by the health
+// check endpoint. Its name and its snake_case keys are the ones
+// GO_HTTP_SERVER_CONVENTIONS.md gives.
+//
+//nolint:revive,tagliatelle // name and keys from the conventions
+type HealthcheckResponse struct {
Appname string `json:"appname"`
Now string `json:"now"`
Status string `json:"status"`
- UptimeHuman string `json:"uptimeHuman"`
- UptimeSeconds int64 `json:"uptimeSeconds"`
+ UptimeHuman string `json:"uptime_human"`
+ UptimeSeconds int64 `json:"uptime_seconds"`
Version string `json:"version"`
}
@@ -65,9 +68,9 @@ func New(
return s, nil
}
-// Check returns the current health status of the application.
-func (s *Healthcheck) Check() *Response {
- return &Response{
+// Healthcheck returns the current health status of the application.
+func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
+ return &HealthcheckResponse{
Appname: s.params.Globals.Appname,
Now: time.Now().UTC().Format(time.RFC3339Nano),
Status: "ok",
--
2.54.0
From 3b1262718d1f4bfef3257aae175147a40650c725 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 03:25:06 +0200
Subject: [PATCH 12/62] Frontend unit tests cover durations, colours,
statistics and health (closes #21)
New table-driven tests in test/unit/main.test.js: humanDuration, the
figure and sparkline colours either side of each latency boundary, a
target's min, max, average and median over an empty history, an
all-unreachable one, a mixed one, one of only answers and one whose
answers have different numbers of digits, and the four health states
either side of their thresholds, with targets found unreachable counted
as timed out. src/main.js exports the four names they need.
package.json gains a test script, which script/frontend-test runs with
the dot reporter and, if a test fails, again with the spec reporter
before failing. NODE_OPTIONS picks the reporter, since yarn appends its
arguments after the test files.
Model: opus-5-5
---
README.md | 9 ++-
TODO.md | 10 +++
package.json | 3 +-
script/frontend-test | 14 +++-
src/main.js | 8 +-
test/unit/main.test.js | 167 ++++++++++++++++++++++++++++++++++++++++-
6 files changed, 197 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index a9c33bb..0a9c256 100644
--- a/README.md
+++ b/README.md
@@ -47,15 +47,17 @@ 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
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag)
-- `script/test` — run `script/frontend-test`, then the backend's Go tests, each
- under its own 30-second timeout
+- `script/test` — run `script/frontend-test`, then `backend/script/test`, the
+ backend's Go tests with the race detector and coverage
- `script/lint` — run eslint, then golangci-lint, both in Docker, by building
the `frontend-lint` and `lint` stages of `Dockerfile` without the cache
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
- `script/check` — run test, lint, and fmt-check
- `script/frontend-test` — run the unit tests in `test/unit/` with Node's
- built-in test runner, then the production build
+ built-in test runner, through the `test` script in `package.json`, and if any
+ 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
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
builds
@@ -267,7 +269,6 @@ properties.
## TODO
-- Add unit tests
- Add configurable host list (environment variable or config file)
- Add latency history export (CSV/JSON)
- Add notification/alert when status changes to DEGRADED
diff --git a/TODO.md b/TODO.md
index 7f13ff4..ca65f2b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,16 @@ latest run passes.
# Completed Steps
+- 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
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
diff --git a/package.json b/package.json
index a268a99..6acde58 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "node --test test/unit/*.test.js"
},
"license": "MIT",
"devDependencies": {
diff --git a/script/frontend-test b/script/frontend-test
index c923f70..bf34e69 100755
--- a/script/frontend-test
+++ b/script/frontend-test
@@ -1,14 +1,22 @@
#!/bin/sh
# script/frontend-test: run the frontend test suite: the unit tests in
-# test/unit/ with Node's built-in test runner, then the production
-# build, which fails on broken code.
+# test/unit/, through the test script in package.json, then the
+# production build, which fails on broken code. The tests print a dot
+# 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
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
- timeout 30 node --test test/unit/*.test.js
+ NODE_OPTIONS=--test-reporter=dot timeout 30 yarn --silent run test || {
+ 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
}
diff --git a/src/main.js b/src/main.js
index df9fae6..2793022 100644
--- a/src/main.js
+++ b/src/main.js
@@ -151,7 +151,7 @@ function formatUTCTimestamp(date) {
// --- Duration Formatting -----------------------------------------------------
-function humanDuration(seconds) {
+export function humanDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
@@ -197,7 +197,7 @@ async function detectGateway() {
// --- App State ---------------------------------------------------------------
-class HostState {
+export class HostState {
constructor(host, pinned = false) {
this.name = host.name;
this.url = host.url;
@@ -546,7 +546,7 @@ export async function measureLatency(url, signal) {
// --- Color Helpers -----------------------------------------------------------
-function latencyHex(latency) {
+export function latencyHex(latency) {
if (latency === null) return "#6b7280";
if (latency < 50) return "#22c55e";
if (latency < 100) return "#84cc16";
@@ -555,7 +555,7 @@ function latencyHex(latency) {
return "#ef4444";
}
-function latencyClass(latency, status) {
+export function latencyClass(latency, status) {
if (status === "offline" || status === "error" || latency === null)
return "text-gray-500";
if (latency < 50) return "text-green-500";
diff --git a/test/unit/main.test.js b/test/unit/main.test.js
index 38b4212..0aea619 100644
--- a/test/unit/main.test.js
+++ b/test/unit/main.test.js
@@ -1,5 +1,6 @@
-// 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.
+// Unit tests for src/main.js, run with Node's built-in test runner by the
+// test script in package.json. Importing the module does not start the
+// page.
import { beforeEach, test } from "node:test";
import assert from "node:assert/strict";
@@ -7,6 +8,10 @@ import {
AppState,
CONFIG,
greyOutUI,
+ HostState,
+ humanDuration,
+ latencyClass,
+ latencyHex,
measureLatency,
tick,
} from "../../src/main.js";
@@ -224,3 +229,161 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
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);
+ });
+}
--
2.54.0
From 9b548da90dccddab999c27d3ab37117123ae478d Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 03:51:55 +0200
Subject: [PATCH 13/62] Move tailwind off the deprecated module.register()
(closes #32)
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.
Deviation: yarn.lock was changed by a raw yarn upgrade; no entrypoint adds a dependency yet (issue #45).
Model: opus-5-5
---
TODO.md | 12 ++
yarn.lock | 384 +++++++++++++++++++++++++++---------------------------
2 files changed, 203 insertions(+), 193 deletions(-)
diff --git a/TODO.md b/TODO.md
index ca65f2b..c5a618c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,18 @@ latest run passes.
# Completed Steps
+- 2026-10-04: a frontend build on Node 26 or newer, such as `make test` on a
+ host with Node 26, no longer prints Node's warning that `module.register()` is
+ deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never
+ printed it. The call was in `@tailwindcss/node`, which `@tailwindcss/vite`
+ brings in at its own exact version; tailwind 4.3.1 calls
+ `module.registerHooks()` instead where Node has it. `yarn.lock` now has
+ `@tailwindcss/vite` and `tailwindcss` at 4.3.3, inside the ranges
+ `package.json` already allowed, and tailwind's own dependencies moved with
+ them. The built CSS changes only in how it is written out, in tailwind's
+ 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
+ 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
diff --git a/yarn.lock b/yarn.lock
index 79b5221..897260d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -20,25 +20,25 @@
hashery "^1.5.1"
keyv "^5.6.0"
-"@emnapi/core@^1.7.1":
- version "1.8.1"
- resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
- integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==
+"@emnapi/core@^1.11.1":
+ version "1.11.3"
+ resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.3.tgz#5e95348a42cd1e06f0b9aa380cd74091daa4d520"
+ integrity sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==
dependencies:
- "@emnapi/wasi-threads" "1.1.0"
+ "@emnapi/wasi-threads" "1.2.3"
tslib "^2.4.0"
-"@emnapi/runtime@^1.7.1":
- version "1.8.1"
- resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5"
- integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==
+"@emnapi/runtime@^1.11.1":
+ version "1.11.3"
+ resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.3.tgz#84257ae3b0531eb2aec1ffa23d70700da007ba95"
+ integrity sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==
dependencies:
tslib "^2.4.0"
-"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.1.0":
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
- integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
+"@emnapi/wasi-threads@1.2.3", "@emnapi/wasi-threads@^1.2.2":
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz#c9bf72fd4be5b928aee894820e8d814ed73916e9"
+ integrity sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==
dependencies:
tslib "^2.4.0"
@@ -264,7 +264,7 @@
"@jridgewell/sourcemap-codec" "^1.5.0"
"@jridgewell/trace-mapping" "^0.3.24"
-"@jridgewell/remapping@^2.3.4":
+"@jridgewell/remapping@^2.3.5":
version "2.3.5"
resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1"
integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
@@ -278,9 +278,9 @@
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
- version "1.5.5"
- resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
- integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d"
+ integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==
"@jridgewell/trace-mapping@^0.3.24":
version "0.3.31"
@@ -303,14 +303,12 @@
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
-"@napi-rs/wasm-runtime@^1.1.0":
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2"
- integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==
+"@napi-rs/wasm-runtime@^1.1.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz#ae38d9ea8483942054c5d82f00c0163374cd1e5f"
+ integrity sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==
dependencies:
- "@emnapi/core" "^1.7.1"
- "@emnapi/runtime" "^1.7.1"
- "@tybys/wasm-util" "^0.10.1"
+ "@tybys/wasm-util" "^0.10.3"
"@puppeteer/browsers@3.1.0":
version "3.1.0"
@@ -445,117 +443,117 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz#6f38851da1123ac0380121108abd31ff21205c3d"
integrity sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==
-"@tailwindcss/node@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.18.tgz#9863be0d26178638794a38d6c7c14666fb992e8a"
- integrity sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==
+"@tailwindcss/node@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.3.3.tgz#38ff04309ff036ea3589a7bad9069c44ec9d3883"
+ integrity sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==
dependencies:
- "@jridgewell/remapping" "^2.3.4"
- enhanced-resolve "^5.18.3"
- jiti "^2.6.1"
- lightningcss "1.30.2"
+ "@jridgewell/remapping" "^2.3.5"
+ enhanced-resolve "^5.24.1"
+ jiti "^2.7.0"
+ lightningcss "1.32.0"
magic-string "^0.30.21"
source-map-js "^1.2.1"
- tailwindcss "4.1.18"
+ tailwindcss "4.3.3"
-"@tailwindcss/oxide-android-arm64@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz#79717f87e90135e5d3d23a3d3aecde4ca5595dd5"
- integrity sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==
+"@tailwindcss/oxide-android-arm64@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz#848f93034155daf7892185028dfb18143bfc7d07"
+ integrity sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==
-"@tailwindcss/oxide-darwin-arm64@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz#7fa47608d62d60e9eb020682249d20159667fbb0"
- integrity sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==
+"@tailwindcss/oxide-darwin-arm64@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz#5c779e32c1c361beb75136fd8e2c627fcb9a5b28"
+ integrity sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==
-"@tailwindcss/oxide-darwin-x64@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz#c05991c85aa2af47bf9d1f8172fe9e4636591e79"
- integrity sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==
+"@tailwindcss/oxide-darwin-x64@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz#ba292ff52fa3264139f7fe7874719ce0a4666875"
+ integrity sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==
-"@tailwindcss/oxide-freebsd-x64@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz#3d48e8d79fd08ece0e02af8e72d5059646be34d0"
- integrity sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==
+"@tailwindcss/oxide-freebsd-x64@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz#07e589487b9a636235a4ade48cefc1f9eeeec57f"
+ integrity sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==
-"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz#982ecd1a65180807ccfde67dc17c6897f2e50aa8"
- integrity sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==
+"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz#0c8abc228d9e19e0065ddb707eba52e21855b3ff"
+ integrity sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==
-"@tailwindcss/oxide-linux-arm64-gnu@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz#df49357bc9737b2e9810ea950c1c0647ba6573c3"
- integrity sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==
+"@tailwindcss/oxide-linux-arm64-gnu@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz#5067dc7afd15d2b97adc77a91177dc4bec8d1b8b"
+ integrity sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==
-"@tailwindcss/oxide-linux-arm64-musl@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz#b266c12822bf87883cf152615f8fffb8519d689c"
- integrity sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==
+"@tailwindcss/oxide-linux-arm64-musl@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz#29ae5f279be2ce64db368711985b6ad8e4562e57"
+ integrity sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==
-"@tailwindcss/oxide-linux-x64-gnu@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz#5c737f13dd9529b25b314e6000ff54e05b3811da"
- integrity sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==
+"@tailwindcss/oxide-linux-x64-gnu@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz#7d693b40f69875744b499481359b227fd3ac3a3c"
+ integrity sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==
-"@tailwindcss/oxide-linux-x64-musl@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz#3380e17f7be391f1ef924be9f0afe1f304fe3478"
- integrity sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==
+"@tailwindcss/oxide-linux-x64-musl@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz#6a55d664f47c5ff9ad3f46bf05fe07d410b8cfd8"
+ integrity sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==
-"@tailwindcss/oxide-wasm32-wasi@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz#9464df0e28a499aab1c55e97682be37b3a656c88"
- integrity sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==
+"@tailwindcss/oxide-wasm32-wasi@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz#408a9bc620e68f1aaf0dfea33da7bd3b65f9e2ef"
+ integrity sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==
dependencies:
- "@emnapi/core" "^1.7.1"
- "@emnapi/runtime" "^1.7.1"
- "@emnapi/wasi-threads" "^1.1.0"
- "@napi-rs/wasm-runtime" "^1.1.0"
- "@tybys/wasm-util" "^0.10.1"
- tslib "^2.4.0"
+ "@emnapi/core" "^1.11.1"
+ "@emnapi/runtime" "^1.11.1"
+ "@emnapi/wasi-threads" "^1.2.2"
+ "@napi-rs/wasm-runtime" "^1.1.4"
+ "@tybys/wasm-util" "^0.10.2"
+ tslib "^2.8.1"
-"@tailwindcss/oxide-win32-arm64-msvc@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz#bbcdd59c628811f6a0a4d5b09616967d8fb0c4d4"
- integrity sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==
+"@tailwindcss/oxide-win32-arm64-msvc@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz#3d582b00180fa4680e0b6c90be69fa5f4a209092"
+ integrity sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==
-"@tailwindcss/oxide-win32-x64-msvc@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz#9c628d04623aa4c3536c508289f58d58ba4b3fb1"
- integrity sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==
+"@tailwindcss/oxide-win32-x64-msvc@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz#ce4c663fef3b4fde67611a4c1391866f8c0aa79b"
+ integrity sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==
-"@tailwindcss/oxide@4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz#c8335cd0a83e9880caecd60abf7904f43ebab582"
- integrity sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==
+"@tailwindcss/oxide@4.3.3":
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz#6266109d025cfcb04f8e4c58954a6f630a415b7f"
+ integrity sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==
optionalDependencies:
- "@tailwindcss/oxide-android-arm64" "4.1.18"
- "@tailwindcss/oxide-darwin-arm64" "4.1.18"
- "@tailwindcss/oxide-darwin-x64" "4.1.18"
- "@tailwindcss/oxide-freebsd-x64" "4.1.18"
- "@tailwindcss/oxide-linux-arm-gnueabihf" "4.1.18"
- "@tailwindcss/oxide-linux-arm64-gnu" "4.1.18"
- "@tailwindcss/oxide-linux-arm64-musl" "4.1.18"
- "@tailwindcss/oxide-linux-x64-gnu" "4.1.18"
- "@tailwindcss/oxide-linux-x64-musl" "4.1.18"
- "@tailwindcss/oxide-wasm32-wasi" "4.1.18"
- "@tailwindcss/oxide-win32-arm64-msvc" "4.1.18"
- "@tailwindcss/oxide-win32-x64-msvc" "4.1.18"
+ "@tailwindcss/oxide-android-arm64" "4.3.3"
+ "@tailwindcss/oxide-darwin-arm64" "4.3.3"
+ "@tailwindcss/oxide-darwin-x64" "4.3.3"
+ "@tailwindcss/oxide-freebsd-x64" "4.3.3"
+ "@tailwindcss/oxide-linux-arm-gnueabihf" "4.3.3"
+ "@tailwindcss/oxide-linux-arm64-gnu" "4.3.3"
+ "@tailwindcss/oxide-linux-arm64-musl" "4.3.3"
+ "@tailwindcss/oxide-linux-x64-gnu" "4.3.3"
+ "@tailwindcss/oxide-linux-x64-musl" "4.3.3"
+ "@tailwindcss/oxide-wasm32-wasi" "4.3.3"
+ "@tailwindcss/oxide-win32-arm64-msvc" "4.3.3"
+ "@tailwindcss/oxide-win32-x64-msvc" "4.3.3"
"@tailwindcss/vite@^4.1.18":
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.1.18.tgz#614b9d5483559518c72d31bca05d686f8df28e9a"
- integrity sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.3.3.tgz#1f4037badcb5e6c6bb574d3d8a0172c83f26b97f"
+ integrity sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==
dependencies:
- "@tailwindcss/node" "4.1.18"
- "@tailwindcss/oxide" "4.1.18"
- tailwindcss "4.1.18"
+ "@tailwindcss/node" "4.3.3"
+ "@tailwindcss/oxide" "4.3.3"
+ tailwindcss "4.3.3"
-"@tybys/wasm-util@^0.10.1":
- version "0.10.1"
- resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
- integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
+"@tybys/wasm-util@^0.10.2", "@tybys/wasm-util@^0.10.3":
+ version "0.10.4"
+ resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.4.tgz#3e85ecb266f9d1722250e89bc69528615720b154"
+ integrity sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==
dependencies:
tslib "^2.4.0"
@@ -722,13 +720,13 @@ emoji-regex@^10.3.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
-enhanced-resolve@^5.18.3:
- version "5.18.4"
- resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828"
- integrity sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==
+enhanced-resolve@^5.24.1:
+ version "5.26.0"
+ resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz#9dfb47d050ed6c87f2c6d43b4d7e30f8c0a91ed7"
+ integrity sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==
dependencies:
graceful-fs "^4.2.4"
- tapable "^2.2.0"
+ tapable "^2.3.3"
esbuild@^0.27.0:
version "0.27.2"
@@ -991,10 +989,10 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
-jiti@^2.6.1:
- version "2.6.1"
- resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92"
- integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
+jiti@^2.7.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
+ integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
json-schema-traverse@^0.4.1:
version "0.4.1"
@@ -1021,79 +1019,79 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
-lightningcss-android-arm64@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz#6966b7024d39c94994008b548b71ab360eb3a307"
- integrity sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==
+lightningcss-android-arm64@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
+ integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
-lightningcss-darwin-arm64@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz#a5fa946d27c029e48c7ff929e6e724a7de46eb2c"
- integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==
+lightningcss-darwin-arm64@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5"
+ integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
-lightningcss-darwin-x64@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz#5ce87e9cd7c4f2dcc1b713f5e8ee185c88d9b7cd"
- integrity sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==
+lightningcss-darwin-x64@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
+ integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
-lightningcss-freebsd-x64@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz#6ae1d5e773c97961df5cff57b851807ef33692a5"
- integrity sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==
+lightningcss-freebsd-x64@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
+ integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
-lightningcss-linux-arm-gnueabihf@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz#62c489610c0424151a6121fa99d77731536cdaeb"
- integrity sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==
+lightningcss-linux-arm-gnueabihf@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
+ integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
-lightningcss-linux-arm64-gnu@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz#2a3661b56fe95a0cafae90be026fe0590d089298"
- integrity sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==
+lightningcss-linux-arm64-gnu@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
+ integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
-lightningcss-linux-arm64-musl@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz#d7ddd6b26959245e026bc1ad9eb6aa983aa90e6b"
- integrity sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==
+lightningcss-linux-arm64-musl@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
+ integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
-lightningcss-linux-x64-gnu@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz#5a89814c8e63213a5965c3d166dff83c36152b1a"
- integrity sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==
+lightningcss-linux-x64-gnu@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
+ integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
-lightningcss-linux-x64-musl@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz#808c2e91ce0bf5d0af0e867c6152e5378c049728"
- integrity sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==
+lightningcss-linux-x64-musl@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
+ integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
-lightningcss-win32-arm64-msvc@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz#ab4a8a8a2e6a82a4531e8bbb6bf0ff161ee6625a"
- integrity sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==
+lightningcss-win32-arm64-msvc@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
+ integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
-lightningcss-win32-x64-msvc@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz#f01f382c8e0a27e1c018b0bee316d210eac43b6e"
- integrity sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==
+lightningcss-win32-x64-msvc@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
+ integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
-lightningcss@1.30.2:
- version "1.30.2"
- resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.30.2.tgz#4ade295f25d140f487d37256f4cd40dc607696d0"
- integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==
+lightningcss@1.32.0:
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9"
+ integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
dependencies:
detect-libc "^2.0.3"
optionalDependencies:
- lightningcss-android-arm64 "1.30.2"
- lightningcss-darwin-arm64 "1.30.2"
- lightningcss-darwin-x64 "1.30.2"
- lightningcss-freebsd-x64 "1.30.2"
- lightningcss-linux-arm-gnueabihf "1.30.2"
- lightningcss-linux-arm64-gnu "1.30.2"
- lightningcss-linux-arm64-musl "1.30.2"
- lightningcss-linux-x64-gnu "1.30.2"
- lightningcss-linux-x64-musl "1.30.2"
- lightningcss-win32-arm64-msvc "1.30.2"
- lightningcss-win32-x64-msvc "1.30.2"
+ lightningcss-android-arm64 "1.32.0"
+ lightningcss-darwin-arm64 "1.32.0"
+ lightningcss-darwin-x64 "1.32.0"
+ lightningcss-freebsd-x64 "1.32.0"
+ lightningcss-linux-arm-gnueabihf "1.32.0"
+ lightningcss-linux-arm64-gnu "1.32.0"
+ lightningcss-linux-arm64-musl "1.32.0"
+ lightningcss-linux-x64-gnu "1.32.0"
+ lightningcss-linux-x64-musl "1.32.0"
+ lightningcss-win32-arm64-msvc "1.32.0"
+ lightningcss-win32-x64-msvc "1.32.0"
locate-path@^6.0.0:
version "6.0.0"
@@ -1287,9 +1285,9 @@ shebang-regex@^3.0.0:
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
source-map-js@^1.2.1:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
- integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab"
+ integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==
string-width@^7.0.0, string-width@^7.2.0:
version "7.2.0"
@@ -1315,15 +1313,15 @@ strip-ansi@^7.1.0, strip-ansi@^7.1.2:
dependencies:
ansi-regex "^6.2.2"
-tailwindcss@4.1.18, tailwindcss@^4.1.18:
- version "4.1.18"
- resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.18.tgz#f488ba47853abdb5354daf9679d3e7791fc4f4e3"
- integrity sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==
+tailwindcss@4.3.3, tailwindcss@^4.1.18:
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55"
+ integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==
-tapable@^2.2.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6"
- integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
+tapable@^2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
+ integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
tinyglobby@^0.2.15:
version "0.2.15"
@@ -1333,7 +1331,7 @@ tinyglobby@^0.2.15:
fdir "^6.5.0"
picomatch "^4.0.3"
-tslib@^2.4.0:
+tslib@^2.4.0, tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
--
2.54.0
From d412815953d45edec75d1de86bc0d05999e90441 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 04:14:59 +0200
Subject: [PATCH 14/62] Shim make dev and make build, format backend/ markdown
(closes #28)
make dev ran yarn dev inline and there was no make build. Both are now
shims, over script/dev and the new script/build. .prettierignore stops
leaving out backend/, so backend/README.md is formatted and checked;
backend/.golangci.yml is left out by name instead: it is the org
standard file whose sha256 backend/script/lint checks, and prettier
would reindent it. .claude/ stays in .prettierignore, as git does not
ignore it. script/install-precommit and the date on bootstrap's pins go
back to the org model; bootstrap, fmt and fmt-check keep what the Go
backend and eslint need, each with a comment saying so.
Model: opus-5-5
---
.prettierignore | 3 ++-
Makefile | 10 +++++++---
README.md | 7 ++++++-
TODO.md | 10 ++++++++++
backend/README.md | 36 ++++++++++++++++++------------------
script/bootstrap | 4 +++-
script/build | 13 +++++++++++++
script/dev | 13 +++++++++++++
script/fmt | 1 +
script/fmt-check | 1 +
script/frontend-fmt | 4 ++--
script/install-precommit | 4 ++--
12 files changed, 78 insertions(+), 28 deletions(-)
create mode 100755 script/build
create mode 100755 script/dev
diff --git a/.prettierignore b/.prettierignore
index d0374bc..ddbaa34 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,6 +1,7 @@
-backend/
dist/
node_modules/
tmp/
yarn.lock
.claude/
+# The org standard file, copied verbatim; backend/script/lint checks its sha256.
+backend/.golangci.yml
diff --git a/Makefile b/Makefile
index 211f8b8..3369fb9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
-.PHONY: bootstrap setup dev test lint fmt fmt-check check frontend-check \
- frontend-viewport-test docker hooks
+.PHONY: bootstrap setup dev build test lint fmt fmt-check check \
+ frontend-check frontend-viewport-test docker hooks
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -13,7 +13,11 @@ setup:
@script/setup
dev:
- yarn dev
+ @script/dev
+
+# The frontend only; backend/Makefile's build target builds the Go server.
+build:
+ @script/build
test:
@script/test
diff --git a/README.md b/README.md
index 0a9c256..b9ab277 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,10 @@ halves, so the root `make check` fails if either one is broken. We provide:
both linters in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
+- `script/dev` — run the Vite dev server, which proxies `/api` to a locally
+ running `netwatch-server`
+- `script/build` — build the frontend for production into `dist/`;
+ `backend/script/build` builds the Go server
- `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run `script/frontend-test`, then `backend/script/test`, the
backend's Go tests with the race detector and coverage
@@ -61,7 +65,8 @@ halves, so the root `make check` fails if either one is broken. We provide:
- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
builds
-- `script/frontend-fmt` — format everything prettier understands (writes)
+- `script/frontend-fmt` — format everything prettier understands (writes), the
+ markdown in `backend/` included
- `script/frontend-fmt-check` — check prettier formatting (read-only)
- `script/frontend-check` — run `script/frontend-test` and
`script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has
diff --git a/TODO.md b/TODO.md
index c5a618c..e234b46 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,16 @@ latest run passes.
# Completed Steps
+- 2026-10-04: `script/` and `Makefile` follow the org models (issue #28):
+ `make dev` shims to the new `script/dev`, the Vite dev server, and the new
+ `make build` to `script/build`, the frontend production build.
+ `.prettierignore` no longer leaves out `backend/`, so `make fmt` and
+ `make fmt-check` cover `backend/README.md`; it leaves out
+ `backend/.golangci.yml` by name, the org standard file whose sha256
+ `backend/script/lint` checks. `script/install-precommit` and the date on
+ `script/bootstrap`'s pins are the org model again; `script/bootstrap`,
+ `script/fmt` and `script/fmt-check` each say in a comment why they differ from
+ it
- 2026-10-04: a frontend build on Node 26 or newer, such as `make test` on a
host with Node 26, no longer prints Node's warning that `module.register()` is
deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never
diff --git a/backend/README.md b/backend/README.md
index 42faf05..a9498d2 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -32,17 +32,16 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
`test`, `fmt` and `fmt-check`:
- `script/build` — compile the static `netwatch-server` binary with its version
- stamped in. The version is `VERSION` from the environment;
- when that is unset or empty, it falls back to `git describe` inside a git
- checkout, then to `dev`
+ stamped in. The version is `VERSION` from the environment; when that is unset
+ or empty, it falls back to `git describe` inside a git checkout, then to `dev`
- `script/test` — run the Go tests with the race detector and coverage. Go's
`-timeout 30s` bounds the tests, not their compile. If they fail, they run
again with `-v` for the details, and the script fails. The race detector needs
a C compiler
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run
- golangci-lint. It runs inside the golangci-lint image of the lint stage of
- the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
- which builds that stage
+ golangci-lint. It runs inside the golangci-lint image of the lint stage of the
+ root `Dockerfile`; from a checkout, run `make lint` at the repo root, which
+ builds that stage
- `script/fmt` — format the Go sources (writes)
- `script/fmt-check` — check Go formatting (read-only)
- `script/run` — build and run the server locally
@@ -61,8 +60,9 @@ flushes them to compressed files on disk for later analysis.
## Design
-The server is structured as an `fx`-wired Go application under `cmd/netwatch-server/`.
-Internal packages in `internal/` follow standard Go project layout:
+The server is structured as an `fx`-wired Go application under
+`cmd/netwatch-server/`. Internal packages in `internal/` follow standard Go
+project layout:
- **`config`**: Loads configuration from environment variables and config files
via Viper.
@@ -89,11 +89,12 @@ Internal packages in `internal/` follow standard Go project layout:
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
-`TRUSTED_PROXIES` defaults to `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`.
-The loopback entries cover a reverse proxy on the same host. A request whose
-direct peer is outside this set has its forwarded headers ignored, and the
-direct peer is logged and rate-limited instead. The container image does not use
-this default; see [Container image](#container-image).
+`TRUSTED_PROXIES` defaults to
+`127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. The loopback
+entries cover a reverse proxy on the same host. A request whose direct peer is
+outside this set has its forwarded headers ignored, and the direct peer is
+logged and rate-limited instead. The container image does not use this default;
+see [Container image](#container-image).
A variable set to a value the server cannot use, such as `PORT=abc`,
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
@@ -109,9 +110,9 @@ only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the
client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on
the `/data` volume; before starting the server, the entrypoint creates it and
gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
-which acts on nothing outside `/data`. nginx replaces the security headers
-this server sets with those in the root `security-headers.conf`, so those are
-what clients of the image see.
+which acts on nothing outside `/data`. nginx replaces the security headers this
+server sets with those in the root `security-headers.conf`, so those are what
+clients of the image see.
The container's own `TRUSTED_PROXIES` goes to nginx instead: IP addresses or
CIDRs, separated by commas, of the reverse proxies in front of the container.
@@ -162,8 +163,7 @@ credentials, so it is bounded instead. Both refusals below answer with the same
to be written fill the cap on their own, and then no file is deleted. At
start, report files past the cap, as after lowering it, are deleted the same
way. So the cap is how much of the newest reports is kept: the default of 1
- GiB is small enough for any host; set it to the space you can give
- `DATA_DIR`.
+ GiB is small enough for any host; set it to the space you can give `DATA_DIR`.
### CORS
diff --git a/script/bootstrap b/script/bootstrap
index 7d06df7..d708c81 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -17,11 +17,13 @@
#
# golangci-lint is not installed: make lint runs it in Docker, which
# this script does not install either.
+#
+# Unlike the org model: Go and gcc for backend/, a newer node for eslint.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
-# Pinned versions, 2026-07-07
+# Pinned versions, 2026-07-06
NODE_VERSION="22.17.0"
# The oldest node the frontend's dependencies accept: the "engines"
# field of eslint 10.12.0, the most demanding of them, asks for 22.13.0
diff --git a/script/build b/script/build
new file mode 100755
index 0000000..68c4bcb
--- /dev/null
+++ b/script/build
@@ -0,0 +1,13 @@
+#!/bin/sh
+# script/build: build the frontend for production into dist/. The Go
+# backend is built by backend/script/build.
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+
+main() {
+ cd "$ROOT"
+ yarn build
+}
+
+main "$@"
diff --git a/script/dev b/script/dev
new file mode 100755
index 0000000..7f879bd
--- /dev/null
+++ b/script/dev
@@ -0,0 +1,13 @@
+#!/bin/sh
+# script/dev: run the frontend's Vite dev server. It proxies /api to a
+# netwatch-server running locally (see vite.config.js).
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+
+main() {
+ cd "$ROOT"
+ yarn dev
+}
+
+main "$@"
diff --git a/script/fmt b/script/fmt
index 7270800..0b3eb52 100755
--- a/script/fmt
+++ b/script/fmt
@@ -1,6 +1,7 @@
#!/bin/sh
# script/fmt: format the whole repo (writes): prettier over everything
# it understands, then gofmt over the Go backend.
+# The org model formats only markdown; this repo also has JS and Go.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
diff --git a/script/fmt-check b/script/fmt-check
index 28518f7..bee557b 100755
--- a/script/fmt-check
+++ b/script/fmt-check
@@ -1,6 +1,7 @@
#!/bin/sh
# script/fmt-check: check formatting across the whole repo (read-only).
# Same scope as script/fmt, but fails instead of writing.
+# The org model checks only markdown; this repo also has JS and Go.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
diff --git a/script/frontend-fmt b/script/frontend-fmt
index 1af33b9..523497a 100755
--- a/script/frontend-fmt
+++ b/script/frontend-fmt
@@ -1,7 +1,7 @@
#!/bin/sh
# script/frontend-fmt: format the frontend and every other file prettier
-# understands, repo-wide (writes). backend/ is in .prettierignore; Go
-# sources are formatted by backend/script/fmt.
+# understands, repo-wide (writes), the markdown in backend/ included.
+# Prettier does not read Go; backend/script/fmt formats the Go sources.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
diff --git a/script/install-precommit b/script/install-precommit
index 723bae1..bef6406 100755
--- a/script/install-precommit
+++ b/script/install-precommit
@@ -8,8 +8,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
- printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
- chmod +x "$hook"
+ printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
+ chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
--
2.54.0
From 81d4153e78238afe6092ea3010456d6d1a6e832d Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 04:58:47 +0200
Subject: [PATCH 15/62] Serve Prometheus metrics at /metrics behind basic auth
(closes #94)
With METRICS_USERNAME and METRICS_PASSWORD both set, the backend
records request metrics through go-http-metrics in a registry of its
own, with Go's runtime and process metrics, and serves them at
GET /metrics behind basic auth; nginx passes /metrics to it. With
neither set there is no such route; one alone, or a METRICS_USERNAME
containing ":", stops the start with an error naming the setting.
Only requests that reach the health check or POST /api/v1/reports are
recorded, as the labels are path and method, which clients could
otherwise make up without end; POST /api/v1/reports is registered by
its full path for that.
Deviation: go get and go mod tidy ran directly; no entrypoint added a Go
dependency yet (issue #45).
Model: opus-5-5
---
Dockerfile | 4 +-
README.md | 12 ++-
TODO.md | 13 ++++
backend/README.md | 42 ++++++++---
backend/go.mod | 16 +++-
backend/go.sum | 46 +++++++++---
backend/internal/config/config.go | 21 ++++++
backend/internal/config/config_test.go | 26 +++++++
backend/internal/middleware/middleware.go | 27 +++++++
backend/internal/server/routes.go | 37 ++++++++--
backend/internal/server/routes_test.go | 89 +++++++++++++++++++++++
nginx.conf | 6 ++
12 files changed, 306 insertions(+), 33 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 66a7d5c..19e1591 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
# The one image netwatch ships: nginx serves the built frontend and
-# passes /api/ and /.well-known/healthcheck to netwatch-server, the Go
-# backend, which runs in the same container on loopback only.
+# passes /api/, /.well-known/healthcheck and /metrics to netwatch-server,
+# the Go backend, which runs in the same container on loopback only.
# bin/entrypoint.sh starts and watches both.
# Lint stage — fast feedback on formatting and lint issues. The
diff --git a/README.md b/README.md
index b9ab277..b4e4de7 100644
--- a/README.md
+++ b/README.md
@@ -201,9 +201,9 @@ static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or
use the Docker image behind a reverse proxy.
The Docker image, built from `Dockerfile`, is the whole service in one
-container: nginx serves the built frontend and passes `/api/` and
-`/.well-known/healthcheck` to the Go backend, `netwatch-server`, which listens
-only inside the container, on `127.0.0.1:8081`. The image:
+container: nginx serves the built frontend and passes `/api/`,
+`/.well-known/healthcheck` and `/metrics` to the Go backend, `netwatch-server`,
+which listens only inside the container, on `127.0.0.1:8081`. The image:
- Listens on port 8080 by default (override with `PORT` env var)
- Takes the client address from `X-Forwarded-For` only on requests from the
@@ -251,6 +251,12 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
connects from one can write its own `X-Forwarded-For`, and through a port
Docker publishes, every client may connect from the Docker network's
gateway, such as `172.17.0.1`.
+ - `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: with both set,
+ the backend records Prometheus metrics of its requests and serves them at
+ `/metrics` on the container port, to requests with this user name and
+ password as their basic auth credentials. With neither set, there are no
+ metrics and `/metrics` is not found. One set without the other, or a user
+ name containing `:`, stops the container
- **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless
both nginx and the backend answer. upaas reads the container's health 60
diff --git a/TODO.md b/TODO.md
index e234b46..5f4e5c6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,19 @@ latest run passes.
# Completed Steps
+- 2026-10-04: the backend serves Prometheus metrics (issue #94). With
+ `METRICS_USERNAME` and `METRICS_PASSWORD` both set, it records request
+ duration and response size through `go-http-metrics` and serves them, with
+ Go's runtime and process metrics, at `GET /metrics` behind basic auth with
+ those credentials; nginx passes `/metrics` to it as it does `/api/`. With
+ neither set there are no metrics and `/metrics` is 404; one without the other
+ stops the start with an error naming both, and so does a `METRICS_USERNAME`
+ containing `:`, with an error naming it. Only requests that reach the health
+ check or `POST /api/v1/reports` are recorded, not `/metrics` itself and not
+ every request as `GO_HTTP_SERVER_CONVENTIONS.md` shows, because the labels are
+ the request's path and method, which clients can make up without end. For
+ that, `POST /api/v1/reports` is now registered by its full path instead of
+ inside a `/api/v1` route group; it answers as before
- 2026-10-04: `script/` and `Makefile` follow the org models (issue #28):
`make dev` shims to the new `script/dev`, the Vite dev server, and the new
`make build` to `script/build`, the frontend production build.
diff --git a/backend/README.md b/backend/README.md
index a9498d2..80ca89f 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -88,6 +88,8 @@ project layout:
| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
+| `METRICS_USERNAME` | empty | Basic auth user name for `/metrics`; see [Metrics](#metrics) |
+| `METRICS_PASSWORD` | empty | Basic auth password for `/metrics`; see [Metrics](#metrics) |
`TRUSTED_PROXIES` defaults to
`127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. The loopback
@@ -103,16 +105,16 @@ starting, with an error naming the variable. An empty variable counts as unset.
### Container image
The root `Dockerfile` builds one image in which nginx listens on the public port
-8080, serves the frontend, and proxies `/api/` and `/.well-known/healthcheck` to
-this server. The image's entrypoint, `bin/entrypoint.sh`, starts the server as
-user `netwatch` (uid 1000) with `BIND_ADDRESS=127.0.0.1` and `PORT=8081`, so
-only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the
-client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on
-the `/data` volume; before starting the server, the entrypoint creates it and
-gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
-which acts on nothing outside `/data`. nginx replaces the security headers this
-server sets with those in the root `security-headers.conf`, so those are what
-clients of the image see.
+8080, serves the frontend, and proxies `/api/`, `/.well-known/healthcheck` and
+`/metrics` to this server. The image's entrypoint, `bin/entrypoint.sh`, starts
+the server as user `netwatch` (uid 1000) with `BIND_ADDRESS=127.0.0.1` and
+`PORT=8081`, so only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`,
+so it takes the client address nginx passes on and no other. `DATA_DIR` is
+`/data/reports`, on the `/data` volume; before starting the server, the
+entrypoint creates it and gives it and `/data` to `netwatch` with
+`netwatch-server prepare-data-dir`, which acts on nothing outside `/data`. nginx
+replaces the security headers this server sets with those in the root
+`security-headers.conf`, so those are what clients of the image see.
The container's own `TRUSTED_PROXIES` goes to nginx instead: IP addresses or
CIDRs, separated by commas, of the reverse proxies in front of the container.
@@ -176,6 +178,26 @@ origin, `scheme://host` with an optional `:port`, as browsers send it: no path,
not even a trailing `/`, and no `*`. Any other entry stops the server from
starting, with an error naming `CORS_ALLOWED_ORIGINS`.
+### Metrics
+
+With both `METRICS_USERNAME` and `METRICS_PASSWORD` set, the server serves
+Prometheus metrics at `GET /metrics` to requests with those as their basic auth
+credentials, and answers any other with 401. For each request that reaches the
+health check or `POST /api/v1/reports`, those the rate limit refuses included,
+the metrics record its duration and response size, labelled with its path,
+method and status; they also count those requests in progress, and include Go's
+runtime and process metrics. No other request is recorded: not those to
+`/metrics` itself, and not those answered before they reach either route, such
+as a CORS preflight, or a request refused with 404 for a path no route has, 405
+for a method its route does not take, or 413 for declaring a body length over
+the 1 MiB limit. A report whose body goes over the limit without declaring its
+length reaches the route, is answered 413 there, and is recorded with that
+status. Clients can make up any number of paths and methods, and each would add
+labels to the metrics for as long as the server runs. With neither set, nothing
+is recorded and `/metrics` answers 404. One without the other stops the server
+from starting, with an error naming both; so does a `METRICS_USERNAME`
+containing `:`, which basic auth cannot carry, with an error naming it.
+
## TODO
- Add integration test that POSTs a report and verifies the compressed output
diff --git a/backend/go.mod b/backend/go.mod
index a2462e8..881081a 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -3,20 +3,29 @@ module sneak.berlin/go/netwatch
go 1.25.5
require (
+ github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.16.0
github.com/joho/godotenv v1.5.1
- github.com/klauspost/compress v1.18.4
+ github.com/klauspost/compress v1.19.1
+ github.com/prometheus/client_golang v1.24.1
+ github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
)
require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.70.1 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -28,6 +37,7 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/sys v0.30.0 // indirect
- golang.org/x/text v0.28.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index cac5e22..ea56244 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,3 +1,9 @@
+github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
+github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -12,26 +18,40 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
+github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
+github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
+github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8=
+github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -42,6 +62,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -54,18 +76,22 @@ go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
-go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
-go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
-golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 0abc49b..348380d 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -44,6 +44,15 @@ var (
errNotPort = errors.New("must be a port number, 1 to 65535")
errNotBool = errors.New("must be true or false")
errNotIP = errors.New("must be an IP address, or empty")
+
+ errMetricsCredentials = errors.New(
+ "METRICS_USERNAME and METRICS_PASSWORD must be set together, " +
+ "or neither",
+ )
+ errMetricsUsernameColon = errors.New(
+ "METRICS_USERNAME must not contain \":\", " +
+ "which basic auth cannot carry in a user name",
+ )
)
// Params defines the dependencies for Config.
@@ -178,6 +187,18 @@ func (s *Config) check() error {
}
}
+ // The server records and serves metrics only with both set, so
+ // one alone is a mistake that would otherwise go unnoticed.
+ if (s.MetricsUsername == "") != (s.MetricsPassword == "") {
+ return errMetricsCredentials
+ }
+
+ // Basic auth splits the credentials at the first ":", so with one
+ // in the user name every request to /metrics would get 401.
+ if strings.Contains(s.MetricsUsername, ":") {
+ return errMetricsUsernameColon
+ }
+
return checkOrigins(s.CORSAllowedOrigins)
}
diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index a75e31e..a6a42e5 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -103,6 +103,32 @@ func TestDataDirMaxBytesMustBeANumber(t *testing.T) {
requireConfigError(t, "DATA_DIR_MAX_BYTES")
}
+// TestMetricsCredentialsGoTogether: with only one of the two set, the
+// server would quietly serve no metrics, so the start fails, naming
+// both.
+func TestMetricsCredentialsGoTogether(t *testing.T) {
+ for _, set := range []string{"METRICS_USERNAME", "METRICS_PASSWORD"} {
+ t.Run(set, func(t *testing.T) {
+ t.Setenv("METRICS_USERNAME", "")
+ t.Setenv("METRICS_PASSWORD", "")
+ t.Setenv(set, "prometheus")
+
+ requireConfigError(t, "METRICS_USERNAME")
+ requireConfigError(t, "METRICS_PASSWORD")
+ })
+ }
+}
+
+// TestMetricsUsernameMustNotContainColon: basic auth splits the
+// credentials at the first ":", so such a user name would get 401 on
+// every request to /metrics.
+func TestMetricsUsernameMustNotContainColon(t *testing.T) {
+ t.Setenv("METRICS_USERNAME", "prom:etheus")
+ t.Setenv("METRICS_PASSWORD", "secret")
+
+ requireConfigError(t, "METRICS_USERNAME")
+}
+
// TestCORSAllowedOriginsMustBeOrigins: "*" would let every origin in,
// and an entry that is not a plain origin would match no page.
func TestCORSAllowedOriginsMustBeOrigins(t *testing.T) {
diff --git a/backend/internal/middleware/middleware.go b/backend/internal/middleware/middleware.go
index aff7864..6d97b6e 100644
--- a/backend/internal/middleware/middleware.go
+++ b/backend/internal/middleware/middleware.go
@@ -18,9 +18,14 @@ import (
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
+ basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
+ "github.com/prometheus/client_golang/prometheus"
+ metrics "github.com/slok/go-http-metrics/metrics/prometheus"
+ ghmm "github.com/slok/go-http-metrics/middleware"
+ "github.com/slok/go-http-metrics/middleware/std"
"go.uber.org/fx"
)
@@ -364,3 +369,25 @@ func (s *Middleware) RateLimit(
),
)
}
+
+// Metrics returns middleware that records each request's duration and
+// response size, and the requests in progress, in registry. They are
+// labelled by the request path.
+func (s *Middleware) Metrics(
+ registry prometheus.Registerer,
+) func(http.Handler) http.Handler {
+ mdlw := ghmm.New(ghmm.Config{
+ Recorder: metrics.NewRecorder(metrics.Config{Registry: registry}),
+ })
+
+ return std.HandlerProvider("", mdlw)
+}
+
+// MetricsAuth returns middleware that lets a request through only with
+// METRICS_USERNAME and METRICS_PASSWORD as its basic auth credentials,
+// and answers any other with 401.
+func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
+ return basicauth.New("metrics", map[string][]string{
+ s.params.Config.MetricsUsername: {s.params.Config.MetricsPassword},
+ })
+}
diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go
index 4c87f59..9f91301 100644
--- a/backend/internal/server/routes.go
+++ b/backend/internal/server/routes.go
@@ -5,6 +5,9 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/collectors"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
)
const (
@@ -29,13 +32,37 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
s.router.Use(middleware.Timeout(requestTimeout))
- s.router.Get(
- "/.well-known/healthcheck",
- s.h.HandleHealthCheck(),
+ // The metrics go in a registry of this server's own, not in
+ // Prometheus' default one, which takes them only once per process.
+ registry := prometheus.NewRegistry()
+ registry.MustRegister(
+ collectors.NewGoCollector(),
+ collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
- s.router.Route("/api/v1", func(r chi.Router) {
+ // Requests are measured only once chi has matched them to one of
+ // these routes, by path and method. The metrics are labelled with
+ // both, which any client can make up, so measuring every request
+ // would let clients add labels without bound. A Route here would
+ // be matched by its path prefix alone, so each path is given in
+ // full.
+ s.router.Group(func(r chi.Router) {
+ // config.New refuses one of the two credentials without the
+ // other.
+ if s.params.Config.MetricsUsername != "" {
+ r.Use(s.mw.Metrics(registry))
+ }
+
+ r.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
+
r.With(s.mw.RateLimit(s.params.Config.ReportsPerMinute)).
- Post("/reports", s.h.HandleReport())
+ Post("/api/v1/reports", s.h.HandleReport())
})
+
+ if s.params.Config.MetricsUsername != "" {
+ s.router.With(s.mw.MetricsAuth()).
+ Get("/metrics", promhttp.HandlerFor(
+ registry, promhttp.HandlerOpts{},
+ ).ServeHTTP)
+ }
}
diff --git a/backend/internal/server/routes_test.go b/backend/internal/server/routes_test.go
index 972debd..8418aa1 100644
--- a/backend/internal/server/routes_test.go
+++ b/backend/internal/server/routes_test.go
@@ -107,6 +107,95 @@ func TestCORSAllowedOriginsReachTheRouter(t *testing.T) {
}
}
+// TestNoMetricsWithoutCredentials: with neither metrics setting set,
+// there is no /metrics.
+func TestNoMetricsWithoutCredentials(t *testing.T) {
+ t.Setenv("METRICS_USERNAME", "")
+ t.Setenv("METRICS_PASSWORD", "")
+
+ srv := newServer(t)
+ srv.SetupRoutes()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(),
+ http.MethodGet, "/metrics", http.NoBody)
+ srv.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
+ }
+}
+
+// TestMetricsBehindBasicAuth: with both metrics settings set, /metrics
+// answers only with them as basic auth credentials, and shows a
+// request to a route but not one to a path no route has.
+func TestMetricsBehindBasicAuth(t *testing.T) {
+ t.Setenv("METRICS_USERNAME", "prometheus")
+ t.Setenv("METRICS_PASSWORD", "right")
+
+ srv := newServer(t)
+ srv.SetupRoutes()
+
+ get := func(path, username, password string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(),
+ http.MethodGet, path, http.NoBody)
+
+ if username != "" {
+ req.SetBasicAuth(username, password)
+ }
+
+ srv.ServeHTTP(rec, req)
+
+ return rec
+ }
+
+ get("/.well-known/healthcheck", "", "")
+ get("/api/v1/no-such-route", "", "")
+
+ for _, creds := range [][2]string{
+ {"", ""},
+ {"prometheus", "wrong"},
+ {"someone", "right"},
+ } {
+ rec := get("/metrics", creds[0], creds[1])
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("credentials %q: status = %d, want %d",
+ creds, rec.Code, http.StatusUnauthorized)
+ }
+ }
+
+ rec := get("/metrics", "prometheus", "right")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("right credentials: status = %d, want %d",
+ rec.Code, http.StatusOK)
+ }
+
+ body := rec.Body.String()
+ if !strings.Contains(body, `handler="/.well-known/healthcheck"`) {
+ t.Errorf("metrics show no health check request:\n%s", body)
+ }
+
+ if strings.Contains(body, "no-such-route") {
+ t.Errorf("metrics show a request to a path no route has:\n%s", body)
+ }
+
+ if !strings.Contains(body, "go_goroutines") {
+ t.Errorf("metrics show no Go runtime metrics:\n%s", body)
+ }
+}
+
+// TestMetricsInTwoServers: two servers in one process can both have
+// metrics on.
+func TestMetricsInTwoServers(t *testing.T) {
+ t.Setenv("METRICS_USERNAME", "prometheus")
+ t.Setenv("METRICS_PASSWORD", "right")
+
+ for range 2 {
+ newServer(t).SetupRoutes()
+ }
+}
+
// TestHealthCheckRejectsOversizeBody sends the health check, which
// never reads its body, a body one byte over the limit. Only the
// router-wide body limit can reject it.
diff --git a/nginx.conf b/nginx.conf
index 3ea963a..d8b0c67 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -68,4 +68,10 @@ server {
location = /.well-known/healthcheck {
proxy_pass http://127.0.0.1:8081;
}
+
+ # The backend's Prometheus metrics, behind its own basic auth. Unless
+ # METRICS_USERNAME and METRICS_PASSWORD are set, it answers 404.
+ location = /metrics {
+ proxy_pass http://127.0.0.1:8081;
+ }
}
--
2.54.0
From dc11beb6fec3bef4bc488a36d1a107bae7382d84 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 05:17:33 +0200
Subject: [PATCH 16/62] Add make add-dependency and make tidy (closes #45)
No entrypoint could change yarn.lock or go.mod: script/bootstrap
installs with --frozen-lockfile, so adding a package meant running
yarn by hand. make add-dependency PACKAGE=@ shims to
the new script/add-dependency: yarn add --dev, then yarn install
--frozen-lockfile. make tidy shims to the new script/tidy, go mod
tidy in backend/; a Go module is added by importing it, or moved by
editing its require line, then make tidy. script/bootstrap is
unchanged.
Model: opus-5-5
---
Makefile | 10 +++++++++-
README.md | 7 +++++++
TODO.md | 8 ++++++++
script/add-dependency | 30 ++++++++++++++++++++++++++++++
script/tidy | 13 +++++++++++++
5 files changed, 67 insertions(+), 1 deletion(-)
create mode 100755 script/add-dependency
create mode 100755 script/tidy
diff --git a/Makefile b/Makefile
index 3369fb9..29d7600 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
.PHONY: bootstrap setup dev build test lint fmt fmt-check check \
- frontend-check frontend-viewport-test docker hooks
+ add-dependency tidy frontend-check frontend-viewport-test docker hooks
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -34,6 +34,14 @@ fmt-check:
check:
@script/check
+# make add-dependency PACKAGE=@. PACKAGE reaches the
+# script through the environment, so the shell never reads it as code.
+add-dependency:
+ @script/add-dependency "$$PACKAGE"
+
+tidy:
+ @script/tidy
+
# The frontend tests and format check, for Dockerfile's frontend stage,
# which has neither Go nor Docker. Use check everywhere else.
frontend-check:
diff --git a/README.md b/README.md
index b4e4de7..a963e6a 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,13 @@ halves, so the root `make check` fails if either one is broken. We provide:
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
- `script/check` — run test, lint, and fmt-check
+- `script/add-dependency` — add a frontend package, or move one to another
+ version: `make add-dependency PACKAGE=@` runs `yarn add --dev`,
+ which changes `package.json` and `yarn.lock` together, then
+ `yarn install --frozen-lockfile`
+- `script/tidy` — run `go mod tidy` in `backend/`: to add a Go module, import it
+ and run `make tidy`; to move one to another version, edit its `require` line
+ in `backend/go.mod`, then run `make tidy`
- `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
fails, run them again listing every test, and fail; then the production build.
diff --git a/TODO.md b/TODO.md
index 5f4e5c6..12cf93b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,14 @@ latest run passes.
# Completed Steps
+- 2026-10-04: a dependency can be added without running yarn or go by hand
+ (issue #45): `make add-dependency PACKAGE=@` shims to the new
+ `script/add-dependency`, which runs `yarn add --dev`, so `package.json` and
+ `yarn.lock` change together, then `yarn install --frozen-lockfile`; the same
+ command moves a package to another version. `make tidy` shims to the new
+ `script/tidy`, which runs `go mod tidy` in `backend/`: a Go module is added by
+ importing it, or moved by editing its `require` line, then `make tidy`.
+ `script/bootstrap` still installs with `--frozen-lockfile`
- 2026-10-04: the backend serves Prometheus metrics (issue #94). With
`METRICS_USERNAME` and `METRICS_PASSWORD` both set, it records request
duration and response size through `go-http-metrics` and serves them, with
diff --git a/script/add-dependency b/script/add-dependency
new file mode 100755
index 0000000..7a4230c
--- /dev/null
+++ b/script/add-dependency
@@ -0,0 +1,30 @@
+#!/bin/sh
+# script/add-dependency: add a frontend package, or move one to another
+# version, with yarn add, which changes package.json and yarn.lock
+# together; then install from yarn.lock with --frozen-lockfile, as
+# script/bootstrap does, to show it installs as written. --dev because
+# no frontend package is needed when the page runs: it ships as the
+# built dist/.
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+
+usage() {
+ echo "usage: make add-dependency PACKAGE=@" >&2
+ exit 2
+}
+
+main() {
+ # Exactly one package. A value beginning with - would reach yarn as
+ # an option; yarn would quietly drop all but the first of several
+ # packages given in one value.
+ [ "$#" -eq 1 ] || usage
+ case "$1" in
+ "" | -* | *[[:space:]]*) usage ;;
+ esac
+ cd "$ROOT"
+ yarn add --dev "$1"
+ yarn install --frozen-lockfile
+}
+
+main "$@"
diff --git a/script/tidy b/script/tidy
new file mode 100755
index 0000000..fadf4cd
--- /dev/null
+++ b/script/tidy
@@ -0,0 +1,13 @@
+#!/bin/sh
+# script/tidy: run go mod tidy in backend/, which adds the modules the
+# Go sources import, drops those they no longer do, and updates go.sum.
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+
+main() {
+ cd "$ROOT/backend"
+ go mod tidy
+}
+
+main "$@"
--
2.54.0
From 00c9f8d7d9f25fa0ef53daf92670e3b3f0f6d09e Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 05:35:36 +0200
Subject: [PATCH 17/62] Target names, URLs and log lines reach the page as text
(closes #29)
A host row escapes the name and URL it writes into its markup with a new
escapeHTML function, and the debug log builds each line as an element
whose text is set, so neither is read as HTML once targets can be
configured. A unit test builds the row of a target whose name and URL
hold < > " & and ' and checks each comes out escaped; hostRowHTML is
exported for it.
README.md stops calling CONFIG frozen: the interval menu sets
updateInterval, and the timeouts, history span and axis ticks are
computed from it. AppState declares _recoveryProbeId and
_recoveryProbeChecks, the sparkline axis functions drop the parameters
they never used, and HostState's history comment names both entry
shapes.
Model: opus-5-5
---
README.md | 6 +++--
TODO.md | 9 ++++++++
src/main.js | 50 ++++++++++++++++++++++++++++++------------
test/unit/main.test.js | 27 +++++++++++++++++++++++
4 files changed, 76 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index a963e6a..e8d492f 100644
--- a/README.md
+++ b/README.md
@@ -111,8 +111,10 @@ files are served, with no backend required.
The application is a single-page app built with Vite and Tailwind CSS v4. All
code lives in `src/main.js` with a class-based architecture:
-- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis
- ticks, etc.)
+- **`CONFIG`**: Configuration object (update interval, timeouts, axis ticks,
+ etc.). The interval menu sets `updateInterval`, the one value the page writes
+ into `CONFIG`; the timeouts, the time the history spans and the x-axis ticks
+ are computed from it
- **`HostState`**: Per-host state management — history buffer, latency tracking,
status transitions
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
diff --git a/TODO.md b/TODO.md
index 12cf93b..1113703 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,15 @@ latest run passes.
# Completed Steps
+- 2026-10-04: a target's name and URL and a debug log message show as the
+ characters they are and are never read as HTML (issue #29): a host row escapes
+ the name and URL it writes into its markup, and the debug log sets each line
+ as text. A unit test checks a target whose name and URL hold `<`, `>`, `"`,
+ `&` and `'`. `README.md` no longer calls `CONFIG` frozen: the interval menu
+ sets its `updateInterval`, and the values computed from it follow. `AppState`
+ declares the recovery probe's two properties, the sparkline axis functions
+ lose the parameters they did not use, and the comment on a target's history
+ names both kinds of entry it holds. Nothing the page does changed
- 2026-10-04: a dependency can be added without running yarn or go by hand
(issue #45): `make add-dependency PACKAGE=@` shims to the new
`script/add-dependency`, which runs `yarn add --dev`, so `package.json` and
diff --git a/src/main.js b/src/main.js
index 2793022..0483ed7 100644
--- a/src/main.js
+++ b/src/main.js
@@ -8,6 +8,8 @@
// display their real value in the latency figure. The history buffer holds
// maxHistoryPoints samples (historyDuration / updateInterval).
// reportInterval is how often collected samples are POSTed to the backend.
+// The interval menu changes updateInterval while the page runs; the
+// getters compute their values from it each time they are read.
export const CONFIG = {
updateInterval: 3000,
maxHistoryPoints: 100,
@@ -201,7 +203,10 @@ export class HostState {
constructor(host, pinned = false) {
this.name = host.name;
this.url = host.url;
- this.history = []; // { timestamp, latency, paused }
+ // Each entry is either a check's result, { timestamp, latency,
+ // error }, or a round skipped while paused, { timestamp,
+ // latency: null, paused: true }.
+ this.history = [];
this.lastLatency = null;
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
this.pinned = pinned;
@@ -271,6 +276,10 @@ export class AppState {
this.local = localHosts.map((h) => new HostState(h));
this.paused = false;
this.tickCount = 0;
+ // The recovery probe's timer, null while it is not running, and the
+ // checks it started last.
+ this._recoveryProbeId = null;
+ this._recoveryProbeChecks = null;
}
get allHosts() {
@@ -580,8 +589,8 @@ class SparklineRenderer {
const ch = h - m.top - m.bottom;
ctx.clearRect(0, 0, w, h);
- SparklineRenderer._drawYAxis(ctx, w, h, m, ch);
- SparklineRenderer._drawXAxis(ctx, w, h, m, cw);
+ SparklineRenderer._drawYAxis(ctx, w, m, ch);
+ SparklineRenderer._drawXAxis(ctx, h, m, cw);
const len = history.length;
const pw = cw / (CONFIG.maxHistoryPoints - 1);
@@ -597,7 +606,7 @@ class SparklineRenderer {
SparklineRenderer._drawTip(ctx, history, getX, getY);
}
- static _drawYAxis(ctx, w, h, m, ch) {
+ static _drawYAxis(ctx, w, m, ch) {
ctx.font = "300 12px monospace";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
@@ -614,7 +623,7 @@ class SparklineRenderer {
}
}
- static _drawXAxis(ctx, w, h, m, cw) {
+ static _drawXAxis(ctx, h, m, cw) {
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (const tick of CONFIG.xAxisTicks) {
@@ -702,7 +711,18 @@ class SparklineRenderer {
// horizontally.
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
-function hostRowHTML(host, index, showPin = true) {
+// Escapes text for HTML, so it shows as written inside an element or a
+// quoted attribute and is never read as markup.
+function escapeHTML(text) {
+ return text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+export function hostRowHTML(host, index, showPin = true) {
const pinColor = host.pinned
? "text-blue-500"
: "text-gray-600 hover:text-gray-400";
@@ -721,12 +741,12 @@ function hostRowHTML(host, index, showPin = true) {
`;
- })
- .join("");
+ const line = document.createElement("div");
+ line.className = levelColors[entry.level] || "text-gray-400";
+ line.textContent = `${ts} ${lvl} ${entry.message}`;
+ return line;
+ }),
+ );
el.scrollTop = el.scrollHeight;
}
diff --git a/test/unit/main.test.js b/test/unit/main.test.js
index 0aea619..a5b9510 100644
--- a/test/unit/main.test.js
+++ b/test/unit/main.test.js
@@ -8,6 +8,7 @@ import {
AppState,
CONFIG,
greyOutUI,
+ hostRowHTML,
HostState,
humanDuration,
latencyClass,
@@ -230,6 +231,32 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
}
});
+// The page shows < > " & and ' in a row's markup as
+// < > " & and '.
+test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {
+ const host = new HostState({
+ name: `"x" & 'y'`,
+ url: `https://x.test/?a="x"&b='y'`,
+ });
+ const row = hostRowHTML(host, 0);
+ assert.doesNotMatch(row, //);
+ assert.ok(
+ row.includes(
+ "><b>"x" & 'y'</b>",
+ ),
+ );
+ assert.ok(
+ row.includes(
+ 'href="https://x.test/<b>?a="x"&b='y'"',
+ ),
+ );
+ assert.ok(
+ row.includes(
+ ">https://x.test/<b>?a="x"&b='y'",
+ ),
+ );
+});
+
for (const [seconds, text] of [
[0, "0s"],
[1, "1s"],
--
2.54.0
From dc2d24072582f65022fdd39d15e59a5d0c2c98a6 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 05:57:02 +0200
Subject: [PATCH 18/62] Report handler panics to Sentry when SENTRY_DSN is set
(closes #95)
With SENTRY_DSN set, the server initialises sentry-go with the release
netwatch-server-, adds the sentryhttp middleware with Repanic
as the last router-wide middleware, after the timeout, and flushes
Sentry for 2 seconds on shutdown. A DSN Sentry refuses stops the start
with an error naming SENTRY_DSN. With it empty, nothing is set up.
The metrics middleware stays on the matched routes only, so it runs
inside the Sentry middleware rather than before it.
Model: opus-5-5
---
README.md | 4 ++
TODO.md | 8 +++
backend/README.md | 11 ++++
backend/cmd/netwatch-server/main_test.go | 22 ++++++++
backend/go.mod | 1 +
backend/go.sum | 24 ++++++---
backend/internal/server/export_test.go | 8 +++
backend/internal/server/routes.go | 7 +++
backend/internal/server/routes_test.go | 67 ++++++++++++++++++++++++
backend/internal/server/server.go | 41 ++++++++++++++-
10 files changed, 184 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index e8d492f..05184c1 100644
--- a/README.md
+++ b/README.md
@@ -266,6 +266,10 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
password as their basic auth credentials. With neither set, there are no
metrics and `/metrics` is not found. One set without the other, or a user
name containing `:`, stops the container
+ - `SENTRY_DSN`, default empty: set to a Sentry project's DSN, the backend
+ sends its errors to that Sentry project: each request whose handling
+ crashes, which still gets a 500 response. A value Sentry does not accept
+ stops the container. Empty, the backend sends nothing to Sentry
- **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless
both nginx and the backend answer. upaas reads the container's health 60
diff --git a/TODO.md b/TODO.md
index 1113703..6b802e0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,14 @@ latest run passes.
# Completed Steps
+- 2026-10-04: the backend reports errors to Sentry (issue #95). With
+ `SENTRY_DSN` set, it sets up `sentry-go` with the release `netwatch-server-`
+ and its version, reports each panic in a handler through `sentryhttp`, the
+ last of the middleware every request goes through, which panics again so the
+ request still gets the 500 from the panic recovery, and waits up to 2 seconds
+ on shutdown for Sentry to finish sending. A DSN Sentry refuses stops the start
+ with an error naming `SENTRY_DSN`. With it empty, Sentry is not set up and
+ nothing is sent to it
- 2026-10-04: a target's name and URL and a debug log message show as the
characters they are and are never read as HTML (issue #29): a host row escapes
the name and URL it writes into its markup, and the debug log sets each line
diff --git a/backend/README.md b/backend/README.md
index 80ca89f..5ac252d 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -90,6 +90,7 @@ project layout:
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
| `METRICS_USERNAME` | empty | Basic auth user name for `/metrics`; see [Metrics](#metrics) |
| `METRICS_PASSWORD` | empty | Basic auth password for `/metrics`; see [Metrics](#metrics) |
+| `SENTRY_DSN` | empty | DSN of the Sentry project to send errors to; see [Sentry](#sentry) |
`TRUSTED_PROXIES` defaults to
`127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. The loopback
@@ -198,6 +199,16 @@ is recorded and `/metrics` answers 404. One without the other stops the server
from starting, with an error naming both; so does a `METRICS_USERNAME`
containing `:`, which basic auth cannot carry, with an error naming it.
+### Sentry
+
+With `SENTRY_DSN` set, the server sends its errors to that Sentry project: each
+panic in a handler is reported there, under the release `netwatch-server-`
+followed by the server's version, and the request still gets 500 from the
+server's panic recovery. On shutdown the server waits up to 2 seconds for Sentry
+to finish sending. A DSN Sentry refuses stops the server from starting, with an
+error naming `SENTRY_DSN`. With it empty, Sentry is not set up, and nothing is
+sent to it.
+
## TODO
- Add integration test that POSTs a report and verifies the compressed output
diff --git a/backend/cmd/netwatch-server/main_test.go b/backend/cmd/netwatch-server/main_test.go
index 964e867..5d9eee5 100644
--- a/backend/cmd/netwatch-server/main_test.go
+++ b/backend/cmd/netwatch-server/main_test.go
@@ -115,6 +115,28 @@ func TestMalformedConfigFileStopsTheStart(t *testing.T) {
}
}
+// TestRefusedSentryDSNStopsTheStart: a SENTRY_DSN that Sentry refuses
+// stops the start, and the error, naming SENTRY_DSN, is logged as JSON.
+func TestRefusedSentryDSNStopsTheStart(t *testing.T) {
+ t.Setenv("SENTRY_DSN", "not-a-dsn")
+
+ ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
+ defer cancel()
+
+ child, stdout, stderr := startServer(ctx, t, t.TempDir(), freePort(ctx, t))
+
+ err := child.Wait()
+ if child.ProcessState.ExitCode() != 1 {
+ t.Fatalf("server exit = %v, want exit status 1", err)
+ }
+
+ requireJSONLines(t, stdout, stderr)
+
+ if !strings.Contains(stdout.String(), "SENTRY_DSN") {
+ t.Fatalf("no error naming SENTRY_DSN in stdout:\n%s", stdout)
+ }
+}
+
// startServer runs main() in a child process listening on
// 127.0.0.1:port, with home as its HOME and working directory and its
// data directory in home, so it touches nothing outside home. Its
diff --git a/backend/go.mod b/backend/go.mod
index 881081a..8e3cf0b 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -4,6 +4,7 @@ go 1.25.5
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
+ github.com/getsentry/sentry-go v0.49.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.16.0
diff --git a/backend/go.sum b/backend/go.sum
index ea56244..2aec8c6 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -4,18 +4,22 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s=
+github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8=
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -36,8 +40,12 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
+github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -46,8 +54,8 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8=
@@ -93,7 +101,7 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/backend/internal/server/export_test.go b/backend/internal/server/export_test.go
index 84cdd90..01e263e 100644
--- a/backend/internal/server/export_test.go
+++ b/backend/internal/server/export_test.go
@@ -1,5 +1,13 @@
package server
+import "github.com/go-chi/chi/v5"
+
+// Router exposes the router to the external tests, which add routes
+// of their own to it after SetupRoutes.
+func (s *Server) Router() *chi.Mux {
+ return s.router
+}
+
// MaxRequestBodyBytes exposes the router-wide body limit to the
// external tests.
const MaxRequestBodyBytes = maxRequestBodyBytes
diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go
index 9f91301..836bbf2 100644
--- a/backend/internal/server/routes.go
+++ b/backend/internal/server/routes.go
@@ -3,6 +3,7 @@ package server
import (
"time"
+ sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
@@ -32,6 +33,12 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
s.router.Use(middleware.Timeout(requestTimeout))
+ // Sentry reports a panic, then panics again, so that s.mw.Recoverer
+ // still answers 500.
+ if s.params.Config.SentryDSN != "" {
+ s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
+ }
+
// The metrics go in a registry of this server's own, not in
// Prometheus' default one, which takes them only once per process.
registry := prometheus.NewRegistry()
diff --git a/backend/internal/server/routes_test.go b/backend/internal/server/routes_test.go
index 8418aa1..d39e86d 100644
--- a/backend/internal/server/routes_test.go
+++ b/backend/internal/server/routes_test.go
@@ -1,10 +1,12 @@
package server_test
import (
+ "io"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
@@ -15,6 +17,7 @@ import (
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
+ "github.com/getsentry/sentry-go"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
@@ -196,6 +199,70 @@ func TestMetricsInTwoServers(t *testing.T) {
}
}
+// TestSentry: with SENTRY_DSN empty there is no Sentry client. With it
+// pointing at a local server standing in for Sentry, a panic in a
+// handler reaches that server, and the request still gets the 500 from
+// the panic recovery.
+func TestSentry(t *testing.T) {
+ const panicMessage = "handler panic for TestSentry"
+
+ // sentry.Init sets the client for the whole process; take it away
+ // again so that no other test reports to Sentry.
+ t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) })
+
+ t.Setenv("SENTRY_DSN", "")
+ newServer(t)
+
+ if sentry.CurrentHub().Client() != nil {
+ t.Fatal("a Sentry client exists with SENTRY_DSN empty")
+ }
+
+ // The body of the first request the stand-in for Sentry receives.
+ received := make(chan string, 1)
+
+ sentryServer := httptest.NewServer(http.HandlerFunc(
+ func(_ http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+
+ select {
+ case received <- string(body):
+ default:
+ }
+ },
+ ))
+ defer sentryServer.Close()
+
+ t.Setenv("SENTRY_DSN",
+ "http://key@"+sentryServer.Listener.Addr().String()+"/1")
+
+ srv := newServer(t)
+ srv.SetupRoutes()
+ srv.Router().Get("/panic", func(http.ResponseWriter, *http.Request) {
+ panic(panicMessage)
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(),
+ http.MethodGet, "/panic", http.NoBody)
+ srv.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d",
+ rec.Code, http.StatusInternalServerError)
+ }
+
+ // Sentry sends from a goroutine of its own.
+ select {
+ case body := <-received:
+ if !strings.Contains(body, panicMessage) {
+ t.Fatalf("the Sentry server received no report of the panic:\n%s",
+ body)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("nothing reached the Sentry server")
+ }
+}
+
// TestHealthCheckRejectsOversizeBody sends the health check, which
// never reads its body, a body one byte over the limit. Only the
// router-wide body limit can reject it.
diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go
index d14dd25..1e828de 100644
--- a/backend/internal/server/server.go
+++ b/backend/internal/server/server.go
@@ -7,8 +7,10 @@ package server
import (
"context"
+ "fmt"
"log/slog"
"net/http"
+ "time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
@@ -16,10 +18,15 @@ import (
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
+ "github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
)
+// sentryFlushTimeout is how long shutdown waits for Sentry to send
+// what it still holds.
+const sentryFlushTimeout = 2 * time.Second
+
// Params defines the dependencies for Server.
type Params struct {
fx.In
@@ -56,6 +63,11 @@ func New(
s.log = params.Logger.Get()
s.shutdowner = params.Shutdowner
+ err := s.enableSentry()
+ if err != nil {
+ return nil, err
+ }
+
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
// Build the router and http.Server synchronously
@@ -87,10 +99,37 @@ func (s *Server) ServeHTTP(
s.router.ServeHTTP(w, r)
}
+// enableSentry sets Sentry up when SENTRY_DSN is set, so that
+// SetupRoutes can report panics to it. With SENTRY_DSN empty it does
+// nothing. A DSN Sentry refuses stops the start.
+func (s *Server) enableSentry() error {
+ if s.params.Config.SentryDSN == "" {
+ return nil
+ }
+
+ err := sentry.Init(sentry.ClientOptions{
+ Dsn: s.params.Config.SentryDSN,
+ Release: s.params.Globals.Appname + "-" + s.params.Globals.Version,
+ })
+ if err != nil {
+ return fmt.Errorf("SENTRY_DSN: %w", err)
+ }
+
+ s.log.Info("sentry error reporting activated")
+
+ return nil
+}
+
// shutdown gracefully stops the HTTP server within the
-// deadline of the context fx provides for OnStop.
+// deadline of the context fx provides for OnStop, then gives
+// Sentry, if set up, time to send what it still holds.
func (s *Server) shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
+
+ if s.params.Config.SentryDSN != "" {
+ sentry.Flush(sentryFlushTimeout)
+ }
+
if err != nil {
s.log.Error("server clean shutdown failed", "error", err)
--
2.54.0
From d40e67d4abec4d3293934b0d34b3f88bd2aec102 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 06:19:06 +0200
Subject: [PATCH 19/62] Rate limit password attempts on /metrics (closes #104)
Each client address may make 60 requests to /metrics a minute,
through the same httprate middleware and TRUSTED_PROXIES
resolution the report route uses, with an allowance of its own.
The limit runs before the basic auth, so past it the answer is 429
and the password is not checked. backend/README.md says so; a test
uses up one client's allowance on wrong passwords, gets 429 with
the right one, and checks that another client behind the same
nginx still gets in.
Model: opus-5-5
---
TODO.md | 7 ++++
backend/README.md | 8 +++++
backend/internal/server/export_test.go | 4 +++
backend/internal/server/routes.go | 18 ++++++++---
backend/internal/server/routes_test.go | 44 ++++++++++++++++++++++++++
5 files changed, 77 insertions(+), 4 deletions(-)
diff --git a/TODO.md b/TODO.md
index 6b802e0..555d543 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,13 @@ latest run passes.
# Completed Steps
+- 2026-10-04: password guesses at `/metrics` are rate limited (issue #104): each
+ client address, resolved through `TRUSTED_PROXIES` as for reports, may make 60
+ requests to `/metrics` a minute, counted by `go-chi/httprate` apart from its
+ reports; past that it gets 429 and its basic auth credentials are not checked.
+ The limit is a constant in `backend/internal/server/routes.go`. A test uses up
+ one client's allowance on wrong passwords, gets 429 with the right one, and
+ checks that another client behind the same nginx still gets in
- 2026-10-04: the backend reports errors to Sentry (issue #95). With
`SENTRY_DSN` set, it sets up `sentry-go` with the release `netwatch-server-`
and its version, reports each panic in a handler through `sentryhttp`, the
diff --git a/backend/README.md b/backend/README.md
index 5ac252d..0cbefe9 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -199,6 +199,14 @@ is recorded and `/metrics` answers 404. One without the other stops the server
from starting, with an error naming both; so does a `METRICS_USERNAME`
containing `:`, which basic auth cannot carry, with an error naming it.
+`/metrics` is rate limited, so that its password cannot be guessed quickly: each
+client address, resolved through `TRUSTED_PROXIES`, may make 60 requests to it a
+minute, whatever their credentials. Past that it gets 429 with
+`Retry-After: 60`, and its credentials are not checked. The minute slides as it
+does for reports (see [Report limits](#report-limits)), so a scraper polling
+every 2 seconds or less often is never refused. This allowance is apart from the
+one for reports.
+
### Sentry
With `SENTRY_DSN` set, the server sends its errors to that Sentry project: each
diff --git a/backend/internal/server/export_test.go b/backend/internal/server/export_test.go
index 01e263e..a113052 100644
--- a/backend/internal/server/export_test.go
+++ b/backend/internal/server/export_test.go
@@ -12,6 +12,10 @@ func (s *Server) Router() *chi.Mux {
// external tests.
const MaxRequestBodyBytes = maxRequestBodyBytes
+// MetricsRequestsPerMinute exposes the /metrics rate limit to the
+// external tests.
+const MetricsRequestsPerMinute = metricsRequestsPerMinute
+
// ListenAddr exposes the address the server listens on to the
// external tests.
func (s *Server) ListenAddr() string {
diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go
index 836bbf2..d13ecf6 100644
--- a/backend/internal/server/routes.go
+++ b/backend/internal/server/routes.go
@@ -18,6 +18,12 @@ const (
// can mount s.mw.MaxBodyBytes with a smaller value to lower
// its bound, but cannot raise it: this cap runs first.
maxRequestBodyBytes int64 = 1 << 20 // 1 MiB
+
+ // metricsRequestsPerMinute is how many requests to /metrics each
+ // client address may make a minute, whatever their credentials. A
+ // scraper polling every 2 seconds sends half of it, which httprate
+ // never refuses.
+ metricsRequestsPerMinute = 60
)
// SetupRoutes configures the chi router with middleware and
@@ -66,10 +72,14 @@ func (s *Server) SetupRoutes() {
Post("/api/v1/reports", s.h.HandleReport())
})
+ // The rate limit comes before the basic auth, so a client past it
+ // gets 429 and its password is not checked.
if s.params.Config.MetricsUsername != "" {
- s.router.With(s.mw.MetricsAuth()).
- Get("/metrics", promhttp.HandlerFor(
- registry, promhttp.HandlerOpts{},
- ).ServeHTTP)
+ s.router.With(
+ s.mw.RateLimit(metricsRequestsPerMinute),
+ s.mw.MetricsAuth(),
+ ).Get("/metrics", promhttp.HandlerFor(
+ registry, promhttp.HandlerOpts{},
+ ).ServeHTTP)
}
}
diff --git a/backend/internal/server/routes_test.go b/backend/internal/server/routes_test.go
index d39e86d..8e076cf 100644
--- a/backend/internal/server/routes_test.go
+++ b/backend/internal/server/routes_test.go
@@ -188,6 +188,50 @@ func TestMetricsBehindBasicAuth(t *testing.T) {
}
}
+// TestMetricsAreRateLimited: a client that has used up its /metrics
+// allowance on wrong passwords gets 429 even with the right one, which
+// is then not checked, while another client behind the same nginx
+// still gets in.
+func TestMetricsAreRateLimited(t *testing.T) {
+ t.Setenv("METRICS_USERNAME", "prometheus")
+ t.Setenv("METRICS_PASSWORD", "right")
+ // As in the container: nginx connects from loopback and names the
+ // client in X-Forwarded-For.
+ t.Setenv("TRUSTED_PROXIES", "127.0.0.1/32")
+
+ srv := newServer(t)
+ srv.SetupRoutes()
+
+ get := func(client, password string) int {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(),
+ http.MethodGet, "/metrics", http.NoBody)
+ req.RemoteAddr = "127.0.0.1:40000"
+ req.Header.Set("X-Forwarded-For", client)
+ req.SetBasicAuth("prometheus", password)
+ srv.ServeHTTP(rec, req)
+
+ return rec.Code
+ }
+
+ for i := range server.MetricsRequestsPerMinute {
+ if code := get("203.0.113.7", "wrong"); code != http.StatusUnauthorized {
+ t.Fatalf("guess %d: status = %d, want %d",
+ i+1, code, http.StatusUnauthorized)
+ }
+ }
+
+ if code := get("203.0.113.7", "right"); code != http.StatusTooManyRequests {
+ t.Fatalf("right password past the limit: status = %d, want %d",
+ code, http.StatusTooManyRequests)
+ }
+
+ if code := get("203.0.113.8", "right"); code != http.StatusOK {
+ t.Fatalf("another client: status = %d, want %d",
+ code, http.StatusOK)
+ }
+}
+
// TestMetricsInTwoServers: two servers in one process can both have
// metrics on.
func TestMetricsInTwoServers(t *testing.T) {
--
2.54.0
From 161f955ae20aa795f3f8221911f08a8d09f53755 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 06:51:24 +0200
Subject: [PATCH 20/62] Latency statistics written once, thresholds read from
CONFIG (closes #102)
A target's min, max, median and average latency now come from one list
of its answers, through latencyStats(), which the summary's figures use
too, so the median is written once. latencyHex() and latencyClass() read
one table of color limits in CONFIG. The health thresholds, the debug
log's length, the gateway check's timeout, the recovery probe's number
of hosts and interval, how often the rows are sorted and the delay
before the first sparkline resize are CONFIG entries, read where the
numbers were. HostState's minLatency(), maxLatency(), averageLatency()
and medianLatency() are gone; the statistics test reads historyStats().
Nothing the page does or shows changes. A unit test now covers the
summary's figures.
Model: opus-5-5
---
TODO.md | 10 +++
src/main.js | 175 ++++++++++++++++++++++-------------------
test/unit/main.test.js | 36 ++++++---
3 files changed, 129 insertions(+), 92 deletions(-)
diff --git a/TODO.md b/TODO.md
index 555d543..4a457e3 100644
--- a/TODO.md
+++ b/TODO.md
@@ -23,6 +23,16 @@ latest run passes.
# Completed Steps
+- 2026-10-04: in `src/main.js` (issue #102), a target's min, max, median and
+ average latency come from one list of its answers, through the same function
+ the summary's figures use, so the median is written once. The latency color
+ limits are one table in `CONFIG`, read by both the figure's and the
+ sparkline's color. The health thresholds, the debug log's length, the gateway
+ check's timeout, the recovery probe's number of hosts and interval, how often
+ the rows are sorted and the delay before the first sparkline resize are
+ `CONFIG` entries too. A unit test checks the summary's figures. Nothing the
+ page does or shows changed; the footer's color legend still writes the limits
+ out as text
- 2026-10-04: password guesses at `/metrics` are rate limited (issue #104): each
client address, resolved through `TRUSTED_PROXIES` as for reports, may make 60
requests to `/metrics` a minute, counted by `go-chi/httprate` apart from its
diff --git a/src/main.js b/src/main.js
index 0483ed7..630c96b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -30,6 +30,39 @@ export const CONFIG = {
return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5));
},
canvasHeight: 96,
+ // A latency figure and its sparkline take the color of the first entry
+ // whose limit, in ms, the latency is below.
+ latencyColors: [
+ { below: 50, hex: "#22c55e", className: "text-green-500" },
+ { below: 100, hex: "#84cc16", className: "text-lime-500" },
+ { below: 200, hex: "#eab308", className: "text-yellow-500" },
+ { below: 500, hex: "#f97316", className: "text-orange-500" },
+ { below: Infinity, hex: "#ef4444", className: "text-red-500" },
+ ],
+ // The health is offline when more than offlineTimeouts WAN hosts timed
+ // out or were unreachable and at most offlineReachable answered;
+ // otherwise degraded when more than degradedTimeouts timed out or were
+ // unreachable; otherwise slow when more than slowHosts answered after
+ // more than slowLatency ms.
+ offlineTimeouts: 10,
+ offlineReachable: 4,
+ degradedTimeouts: 4,
+ slowHosts: 3,
+ slowLatency: 1000,
+ // The debug log keeps its last maxLogEntries lines.
+ maxLogEntries: 1000,
+ // A gateway candidate that has not answered after gatewayTimeout ms is
+ // passed over.
+ gatewayTimeout: 1500,
+ // When no WAN host answers, the recovery probe checks recoveryProbeHosts
+ // random ones every recoveryProbeInterval ms.
+ recoveryProbeHosts: 4,
+ recoveryProbeInterval: 500,
+ // The rows are sorted after the first round that is not discarded, then
+ // every roundsPerSort rounds.
+ roundsPerSort: 10,
+ // The sparklines are sized and drawn again resizeDelay ms after start.
+ resizeDelay: 100,
};
// WAN endpoints to monitor. These are used for the aggregate health/stats
@@ -114,7 +147,8 @@ const debugLog = [];
const log = (() => {
function append(level, message) {
debugLog.push({ timestamp: new Date(), level, message });
- if (debugLog.length > 1000) debugLog.splice(0, debugLog.length - 1000);
+ if (debugLog.length > CONFIG.maxLogEntries)
+ debugLog.splice(0, debugLog.length - CONFIG.maxLogEntries);
const panel = document.getElementById("debug-panel");
if (panel && !panel.classList.contains("hidden")) renderDebugLog();
}
@@ -174,7 +208,10 @@ async function detectGateway() {
const result = await Promise.any(
GATEWAY_CANDIDATES.map(async (url) => {
const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 1500);
+ const timeoutId = setTimeout(
+ () => controller.abort(),
+ CONFIG.gatewayTimeout,
+ );
try {
await fetch(url, {
method: "GET",
@@ -199,6 +236,27 @@ async function detectGateway() {
// --- App State ---------------------------------------------------------------
+// The min, max, median and average of latencies, a list of numbers, or all
+// null when it is empty. The median of an even count is the mean of the
+// middle two; it and the average are rounded.
+function latencyStats(latencies) {
+ if (latencies.length === 0)
+ return { min: null, max: null, med: null, avg: null };
+ const sorted = [...latencies].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return {
+ min: sorted[0],
+ max: sorted[sorted.length - 1],
+ med:
+ sorted.length % 2
+ ? sorted[mid]
+ : Math.round((sorted[mid - 1] + sorted[mid]) / 2),
+ avg: Math.round(
+ latencies.reduce((a, b) => a + b, 0) / latencies.length,
+ ),
+ };
+}
+
export class HostState {
constructor(host, pinned = false) {
this.name = host.name;
@@ -230,38 +288,16 @@ export class HostState {
this._trim();
}
- averageLatency() {
- const valid = this.history.filter((p) => p.latency !== null);
- if (valid.length === 0) return null;
- return Math.round(
- valid.reduce((s, p) => s + p.latency, 0) / valid.length,
+ // The min, max, median and average latency of the checks in the history
+ // that got an answer.
+ historyStats() {
+ return latencyStats(
+ this.history
+ .filter((p) => p.latency !== null)
+ .map((p) => p.latency),
);
}
- minLatency() {
- const valid = this.history.filter((p) => p.latency !== null);
- if (valid.length === 0) return null;
- return Math.min(...valid.map((p) => p.latency));
- }
-
- maxLatency() {
- const valid = this.history.filter((p) => p.latency !== null);
- if (valid.length === 0) return null;
- return Math.max(...valid.map((p) => p.latency));
- }
-
- medianLatency() {
- const sorted = this.history
- .filter((p) => p.latency !== null)
- .map((p) => p.latency)
- .sort((a, b) => a - b);
- if (sorted.length === 0) return null;
- const mid = Math.floor(sorted.length / 2);
- return sorted.length % 2
- ? sorted[mid]
- : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
- }
-
_trim() {
while (this.history.length > CONFIG.maxHistoryPoints)
this.history.shift();
@@ -288,33 +324,13 @@ export class AppState {
/** WAN-only stats from latest sample (excludes local) */
wanStats() {
- const reachable = this.wan.filter((h) => h.lastLatency !== null);
- const latencies = reachable.map((h) => h.lastLatency);
- const total = this.wan.length;
- if (latencies.length === 0)
- return {
- reachable: 0,
- total,
- min: null,
- max: null,
- med: null,
- avg: null,
- };
- const sorted = [...latencies].sort((a, b) => a - b);
- const mid = Math.floor(sorted.length / 2);
- const med =
- sorted.length % 2
- ? sorted[mid]
- : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
+ const latencies = this.wan
+ .filter((h) => h.lastLatency !== null)
+ .map((h) => h.lastLatency);
return {
reachable: latencies.length,
- total,
- min: Math.min(...latencies),
- max: Math.max(...latencies),
- med,
- avg: Math.round(
- latencies.reduce((a, b) => a + b, 0) / latencies.length,
- ),
+ total: this.wan.length,
+ ...latencyStats(latencies),
};
}
@@ -340,12 +356,16 @@ export class AppState {
const timeouts = this.wan.filter(
(h) => h.status === "error" || h.status === "offline",
).length;
- if (timeouts > 10 && reachable <= 4) return "offline";
- if (timeouts > 4) return "degraded";
+ if (
+ timeouts > CONFIG.offlineTimeouts &&
+ reachable <= CONFIG.offlineReachable
+ )
+ return "offline";
+ if (timeouts > CONFIG.degradedTimeouts) return "degraded";
const slow = this.wan.filter(
- (h) => h.lastLatency !== null && h.lastLatency > 1000,
+ (h) => h.lastLatency !== null && h.lastLatency > CONFIG.slowLatency,
).length;
- if (slow > 3) return "slow";
+ if (slow > CONFIG.slowHosts) return "slow";
return "healthy";
}
@@ -557,21 +577,13 @@ export async function measureLatency(url, signal) {
export function latencyHex(latency) {
if (latency === null) return "#6b7280";
- if (latency < 50) return "#22c55e";
- if (latency < 100) return "#84cc16";
- if (latency < 200) return "#eab308";
- if (latency < 500) return "#f97316";
- return "#ef4444";
+ return CONFIG.latencyColors.find((c) => latency < c.below).hex;
}
export function latencyClass(latency, status) {
if (status === "offline" || status === "error" || latency === null)
return "text-gray-500";
- if (latency < 50) return "text-green-500";
- if (latency < 100) return "text-lime-500";
- if (latency < 200) return "text-yellow-500";
- if (latency < 500) return "text-orange-500";
- return "text-red-500";
+ return CONFIG.latencyColors.find((c) => latency < c.below).className;
}
// --- Sparkline Renderer ------------------------------------------------------
@@ -912,10 +924,7 @@ function updateHostRow(host, index) {
latencyEl.innerHTML = `---`;
}
- const avg = host.averageLatency();
- const med = host.medianLatency();
- const min = host.minLatency();
- const max = host.maxLatency();
+ const { min, med, avg, max } = host.historyStats();
if (host.status === "online" && avg !== null) {
statusEl.innerHTML = statusStatsHTML([
["min", min],
@@ -1182,8 +1191,9 @@ export async function tick(state, signal, onOffline) {
// rows whose check ended before the resume still read "paused"
state.allHosts.forEach((host, i) => updateHostRow(host, i));
- // Sort after the first real check, then every 10 ticks thereafter
- if (state.tickCount === 2 || state.tickCount % 10 === 1) {
+ // Sort after the first real check, then every CONFIG.roundsPerSort
+ // ticks thereafter
+ if (state.tickCount === 2 || state.tickCount % CONFIG.roundsPerSort === 1) {
sortAndRebuildWAN(state);
}
@@ -1206,9 +1216,10 @@ export async function tick(state, signal, onOffline) {
// --- Recovery Probe ----------------------------------------------------------
-// When offline, check 4 random WAN hosts every 500ms, giving up the checks
-// started 500ms before, so at most 4 are ever waiting. As soon as one
-// answers, stop probing and start a new round at once.
+// When offline, check CONFIG.recoveryProbeHosts random WAN hosts every
+// CONFIG.recoveryProbeInterval ms, giving up the checks started one interval
+// before, so at most that many are ever waiting. As soon as one answers,
+// stop probing and start a new round at once.
function startRecoveryProbe(state, startRounds) {
if (state._recoveryProbeId) return; // already running
const candidates = [...state.wan];
@@ -1216,7 +1227,7 @@ function startRecoveryProbe(state, startRounds) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
- const canaries = candidates.slice(0, 4);
+ const canaries = candidates.slice(0, CONFIG.recoveryProbeHosts);
log.notice(
`Recovery probe started (${canaries.map((h) => h.name).join(", ")})`,
);
@@ -1233,7 +1244,7 @@ function startRecoveryProbe(state, startRounds) {
startRounds();
});
}
- }, 500);
+ }, CONFIG.recoveryProbeInterval);
}
function stopRecoveryProbe(state) {
@@ -1497,7 +1508,7 @@ async function init() {
});
window.addEventListener("resize", () => handleResize(state));
- setTimeout(() => handleResize(state), 100);
+ setTimeout(() => handleResize(state), CONFIG.resizeDelay);
}
// Bootstrap only when loaded as the page: a real DOM containing the #app
diff --git a/test/unit/main.test.js b/test/unit/main.test.js
index a5b9510..0b037ab 100644
--- a/test/unit/main.test.js
+++ b/test/unit/main.test.js
@@ -347,19 +347,35 @@ for (const { history, latencies, statistics } of [
},
]) {
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,
- );
+ const { min, max, avg, med } = hostAfter(latencies).historyStats();
+ assert.deepEqual({ min, max, average: avg, median: med }, statistics);
});
}
+// The summary's figures come from each WAN target's last check, by the same
+// rules as a target's own: here four answered, one was found unreachable
+// and the rest have not been checked yet. The median, 22.5, and the
+// average, 21.25, are rounded.
+test("the summary's min, max, median and average latency over the WAN targets' last checks", () => {
+ const state = new AppState([]);
+ [30, 10, null, 25, 20].forEach((latency, i) =>
+ state.wan[i].pushSample(
+ Date.now(),
+ latency === null
+ ? { latency: null, error: "unreachable" }
+ : { latency, error: null },
+ ),
+ );
+ assert.deepEqual(state.wanStats(), {
+ reachable: 4,
+ total: state.wan.length,
+ min: 10,
+ max: 30,
+ med: 23,
+ avg: 21,
+ });
+});
+
// 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.
--
2.54.0
From 64e142c17f4f3fb1b744fec092a7cf8d5ebcb893 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 07:03:05 +0200
Subject: [PATCH 21/62] README, TODO and the viewport README say what the tree
does (closes #24)
README.md: Getting Started leads with make targets; a Backend section
gives netwatch-server's routes and how the image builds and runs it;
the checks are GET requests; the WAN host list, health states, summary
figures, sorting and missing features match src/main.js; the TODO
section points to TODO.md, which holds the one to-do list.
backend/README.md: its TODO section points to TODO.md too, whose
Future Steps take its three open items.
TODO.md: Workflow branches from next and opens the PR against next;
Status, Next Step and Future Steps describe the open work, linked to
its issue where one exists.
test/viewport/README.md: the unit tests run on Node's test runner,
not vitest.
Model: opus-5-5
---
README.md | 158 ++++++++++++++++++++++++++--------------
TODO.md | 53 +++++++++-----
backend/README.md | 5 +-
test/viewport/README.md | 15 ++--
4 files changed, 147 insertions(+), 84 deletions(-)
diff --git a/README.md b/README.md
index 05184c1..c3a11e0 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,33 @@
NetWatch is an MIT-licensed JavaScript single-page application by
[@sneak](https://sneak.berlin) that provides real-time network latency
monitoring to common internet hosts, displayed with color-coded figures and
-sparkline graphs, served from a static bucket or Docker container.
+sparkline graphs, served from a static bucket or from its Docker image, where a
+small Go backend stores the measurements the page reports.
## Getting Started
```bash
-# Install dependencies
-yarn install
+# Install the dependencies and the git pre-commit hook
+make setup
-# Development server
-yarn dev
+# Run the page on the Vite dev server
+make dev
-# Production build
-yarn build
+# Run the tests, both linters and the format check
+make check
-# Preview production build
-yarn preview
+# Build the page into dist/
+make build
-# Docker
-docker build -t netwatch .
+# Build the image and run it
+make docker
docker run -p 8080:8080 netwatch
```
-`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running
-`netwatch-server` (see `backend/`) receives the reports the page posts.
+`make check` and `make docker` need Docker. `make dev` passes `/api` to
+`http://127.0.0.1:8080`, where `make run` in `backend/` starts `netwatch-server`
+with its defaults, so the reports the page posts are stored in
+`backend/data/reports`.
## Entrypoints
@@ -94,22 +97,25 @@ halves, so the root `make check` fails if either one is broken. We provide:
The narrow-viewport layout lives in the `max-width: 768px` media block in
`src/styles.css`. It is verified automatically by `make frontend-viewport-test`,
which drives a digest-pinned headless Chrome against the built `dist/` and
-asserts on computed layout at widths derived from that CSS — one pixel either
-side of every breakpoint it declares, plus a 320px floor, a desktop baseline and
-two landscape sizes. See [test/viewport/README.md](test/viewport/README.md) for
-what it covers and what it genuinely cannot.
+asserts on computed layout at widths derived from that CSS — on every breakpoint
+it declares and one pixel either side of it, plus a 320px floor, a desktop
+baseline and two landscape sizes. See
+[test/viewport/README.md](test/viewport/README.md) for what it covers and what
+it genuinely cannot.
## Rationale
When debugging network issues, it's useful to have a persistent at-a-glance view
of latency and reachability to multiple well-known internet endpoints. NetWatch
-provides this as a zero-dependency SPA that can be deployed anywhere static
-files are served, with no backend required.
+provides this as a single page that does all its measuring in the browser, so it
+can be served from anywhere static files are served. The backend in its Docker
+image only stores the measurements the page reports; without it, the page works
+the same and nothing is stored.
## Design
-The application is a single-page app built with Vite and Tailwind CSS v4. All
-code lives in `src/main.js` with a class-based architecture:
+The page is built with Vite and Tailwind CSS v4. Its code is all in
+`src/main.js`, with a class-based architecture:
- **`CONFIG`**: Configuration object (update interval, timeouts, axis ticks,
etc.). The interval menu sets `updateInterval`, the one value the page writes
@@ -125,10 +131,10 @@ code lives in `src/main.js` with a class-based architecture:
`updateSummary()` / `updateHealthBox()` handle incremental updates
- **`tick()`**: Main loop — measures all hosts in parallel, pushing each host's
sample and redrawing its row as soon as its check ends, then redraws every
- 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)
+ row, the summary and the health box once the last check ends. The first round,
+ after loading or an interval change, is discarded. The rows are sorted when
+ the last check ends in round 2, the first one kept, and in rounds 11, 21, 31
+ and so on. When paused, pushes blank markers (no probes, no false outage)
- **`Reporter`**: Posts collected samples to the backend
### Reporting
@@ -142,12 +148,35 @@ delivered report, and while paused nothing is sent. Delivery failure is quiet
one debug-log line per outage, retried at the next interval, never blocking
probing. The report-building step is a pure function of host state.
+### Backend
+
+`netwatch-server`, in `backend/`, is a small Go HTTP server that stores the
+reports the page posts. It keeps them in memory and writes them to `DATA_DIR` as
+zstd-compressed files of JSON lines: every minute, whenever 10 MiB are waiting,
+and when it stops. Its routes:
+
+- `POST /api/v1/reports` — takes a report, without credentials; each client
+ address may send a limited number a minute, and the report files are capped in
+ size, the oldest deleted first
+- `GET /.well-known/healthcheck` — answers 200 with `"status":"ok"`, the
+ server's version and its uptime
+- `GET /metrics` — Prometheus metrics behind basic auth, only when
+ `METRICS_USERNAME` and `METRICS_PASSWORD` are set; each client address may
+ make a limited number of requests to it a minute
+
+In the image, the `builder` stage of `Dockerfile` tests it and builds it with
+`backend/script/build`, and `bin/entrypoint.sh` runs it as user `netwatch` on
+`127.0.0.1:8081`, behind nginx. Outside the image, `make run` in `backend/`
+builds it and runs it on port 8080. Its settings, report storage and limits are
+in [backend/README.md](backend/README.md).
+
### Monitoring targets
-- **22 WAN hosts**: datavi.be, Anthropic API, OpenAI API, AWS Console, GCP
- Console, Azure, Cloudflare, Fastly, Akamai, GitHub, B2, 7 S3 regional
- endpoints (Cape Town, London, Bahrain, Tokyo, Sydney, Oregon, São Paulo), 4
- GCS locational endpoints (Iowa, Belgium, Singapore, Sydney)
+- **26 WAN hosts**: datavi.be (pinned at start), Anthropic API, OpenAI API, AWS
+ Console, Google Cloud Console, Microsoft Azure, Cloudflare, Fastly CDN,
+ Akamai, Google, GitHub, B2, 8 S3 regional endpoints (Cape Town, London,
+ Bahrain, Tokyo, Singapore, Sydney, Oregon, São Paulo) and 6 Hetzner speed test
+ servers (Nuremberg, Falkenstein, Helsinki, Ashburn, Hillsboro, Singapore)
- **Local CPE**: Cable modem at 192.168.100.1 (always monitored)
- **Local Gateway**: Auto-detected on startup by probing common default gateway
addresses (192.168.1.1, 192.168.0.1, 192.168.8.1, 10.0.0.1); first responder
@@ -159,15 +188,17 @@ Local hosts are tracked separately from WAN stats.
### Latency measurement
-HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with
-`performance.now()`. Each check times out after 80% of the refresh interval (24
-seconds at 30 seconds) and is then recorded as a timeout, so a round's checks
-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
-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
-last round's checks if they are still waiting, and that round records nothing
-more, so rounds never overlap. IPv4 only.
+GET requests with `mode: 'no-cors'`, `cache: 'no-store'` and a cache-busting
+query parameter, timed with `performance.now()`. Each check times out after 80%
+of the refresh interval (24 seconds at 30 seconds) and is then recorded as a
+timeout, so a round's checks have all finished before the next round is due.
+When no WAN host answers, a recovery probe checks 4 WAN hosts, picked at random
+when it starts, every half second, giving up the checks 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 last round's checks if they
+are still waiting, and that round records nothing more, so rounds never overlap.
+The browser chooses between IPv4 and IPv6 for each target, as for any request;
+the local targets are IPv4 addresses.
### Color coding
@@ -192,25 +223,42 @@ dist/
## Features
-- Real-time monitoring with 2s update interval and 300s history sparklines
-- Health indicator: green (HEALTHY) or red (DEGRADED) based on WAN reachability
-- Summary stats: reachable count, min/max/avg latency across WAN hosts only
-- Fixed chart axes: Y-axis 0–1000ms, X-axis 0–300s
+- A round of checks every 3 seconds by default; the interval menu sets 1, 2, 3,
+ 5, 10, 15, 30 or 60 seconds and clears the history
+- Sparklines of each target's last 100 rounds: 300 seconds at 3 seconds
+- The first round after loading or an interval change is discarded, as DNS and
+ TLS setup inflate its latencies
+- Health indicator from the WAN hosts' latest results: OFFLINE (red) when more
+ than 10 fail and at most 4 answer, otherwise DEGRADED (orange) when more than
+ 4 fail, otherwise SLOW (yellow) when more than 3 take over 1000ms, otherwise
+ HEALTHY (green)
+- Summary stats across WAN hosts only: how many answered, the min, median,
+ average and max of their latest latencies, the min and max over the whole
+ history, and the number of rounds run (`Checks`)
+- Fixed chart axes: Y-axis 0–1000ms, higher latencies drawn at the top; X-axis
+ the time the history spans
- Color-coded latency figures and sparkline line segments
+- WAN host rows sorted by latest latency, unreachable last; pinned rows stay on
+ top, in name order
- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no
false outage)
+- Debug log panel, behind a checkbox in the footer, with five levels (error,
+ warning, notice, info, debug) and the last 1000 lines
+- Local and UTC clocks
- Clickable service URLs
+- A footer link to the commit the page was built from
- Canvas-based sparkline rendering with devicePixelRatio scaling
- Zero runtime dependencies: all resources bundled into build artifacts
## Deployment
-After running `yarn build`, deploy the contents of the `dist/` directory to any
-static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or
-use the Docker image behind a reverse proxy.
+`make build` writes the page to `dist/`, which any static file host (S3, GCS,
+Cloudflare Pages, Vercel, Netlify, GitHub Pages) can serve; with no backend
+there, its reports fail quietly and nothing is stored. Or run the Docker image
+behind a reverse proxy.
-The Docker image, built from `Dockerfile`, is the whole service in one
-container: nginx serves the built frontend and passes `/api/`,
+The Docker image, built from `Dockerfile` by `make docker`, is the whole service
+in one container: nginx serves the built frontend and passes `/api/`,
`/.well-known/healthcheck` and `/metrics` to the Go backend, `netwatch-server`,
which listens only inside the container, on `127.0.0.1:8081`. The image:
@@ -283,19 +331,19 @@ properties.
## Limitations
-- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
- `no-cors` mode which allows the request but provides opaque responses. Latency
- is still measurable based on request timing.
-- **Local gateway**: The 192.168.100.1 endpoint requires the host to be
- accessible from your network.
+- **CORS**: The checks are cross-origin requests in `no-cors` mode, so the page
+ cannot read the answer, only time it: any answer counts as reachable, an error
+ page included.
+- **Local targets**: The cable modem at 192.168.100.1 and the detected gateway
+ answer only on a network that has them, and only when NetWatch is served from
+ localhost or a private address (see Monitoring targets).
- **Network conditions**: Measurements reflect browser-to-endpoint latency,
which includes your local network, ISP, and internet routing.
## TODO
-- Add configurable host list (environment variable or config file)
-- Add latency history export (CSV/JSON)
-- Add notification/alert when status changes to DEGRADED
+The to-do list is [TODO.md](TODO.md): where the work stands, the next step, the
+open work, and what has been done.
## License
diff --git a/TODO.md b/TODO.md
index 4a457e3..eea61eb 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,28 +1,38 @@
# Workflow
-- branch (from `main`)
+- branch from `next`
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
-- merge to `main` if the branch is not protected, otherwise open a PR
-- push
+- push the branch and open a PR against `next`
# Status
-pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI
-workflow, and the backend repo standard files are all on `main`. Frontend and
-backend are both functional. Working toward the 1.0.0 milestone by closing the
-remaining repo-compliance issues on the tracker.
+pre-1.0. No git tags. `main` is the stable branch and `next` the development
+branch, which every PR targets. The frontend and the Go backend ship as one
+Docker image, and the Gitea workflow `.gitea/workflows/check.yml` runs
+`script/cibuild` on every push. Working toward 1.0.0.
# Next Step
-Confirm the `.gitea/workflows/check.yml` run is green (main always green
-policy). The workflow file is already on `main`; what is unverified is that its
-latest run passes.
+Take "IPv4 only" out of the page's footer, as nothing in the page limits a check
+to IPv4 ([#111](https://git.eeqj.de/sneak/netwatch/issues/111)).
# Completed Steps
+- 2026-10-04: `README.md`, `TODO.md` and `test/viewport/README.md` say what the
+ tree does (issue #24). The README's Getting Started leads with `make` targets;
+ a new Backend section says what `netwatch-server` stores, its routes and how
+ the image builds and runs it, and points to `backend/README.md` for its
+ settings; the checks are GET requests; the 26 WAN hosts, the four health
+ states, the summary's figures and the features the list lacked are described
+ as the page has them; and its TODO section points here, as does the one in
+ `backend/README.md`, whose open items moved to Future Steps. This file's
+ Workflow branches from `next` and opens the PR against `next`, Status says
+ where the repo stands, and Next Step and Future Steps hold only open work,
+ linked to its issue where one exists. The viewport harness README names Node's
+ test runner, not `vitest`
- 2026-10-04: in `src/main.js` (issue #102), a target's min, max, median and
average latency come from one list of its answers, through the same function
the summary's figures use, so the median is written once. The latency color
@@ -374,12 +384,17 @@ latest run passes.
# Future Steps
-- Wire `script/frontend-viewport-test` into CI as its own step (deliberately not
- part of `make check` today; the decision has real CI-runtime cost and is
- tracked separately)
-- Compliance top-up as one small commit: add .editorconfig and add the hooks
- target to the Makefile
-- After merge, confirm .gitea/workflows/check.yml is on main and CI is green
- (main always green policy)
-- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
- it
+- Decide whether the repo moves to the layout `REPO_POLICIES.md` gives, with
+ `backend/` no longer repeating files from the root
+ ([#30](https://git.eeqj.de/sneak/netwatch/issues/30))
+- Run `make frontend-viewport-test` in CI as its own step; it is not part of
+ `make check`, as it needs Docker and takes minutes
+- A backend test that posts a report to `POST /api/v1/reports` and checks the
+ compressed file it is written to
+- A backend route that decompresses the stored reports and answers queries on
+ them
+- Prometheus metrics for the backend's in-memory buffer: its size, the number of
+ flushes and the number of reports
+- A configurable host list (an environment variable or a config file)
+- Export of the latency history (CSV or JSON)
+- A notification when the health status changes to DEGRADED
diff --git a/backend/README.md b/backend/README.md
index 0cbefe9..47f74ee 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -219,9 +219,8 @@ sent to it.
## TODO
-- Add integration test that POSTs a report and verifies the compressed output
-- Add report decompression/query endpoint
-- Add metrics (Prometheus) for buffer size, flush count, report count
+The to-do list, this backend's open work included, is [TODO.md](../TODO.md) at
+the repo root.
## License
diff --git a/test/viewport/README.md b/test/viewport/README.md
index 9ee28a5..8e89805 100644
--- a/test/viewport/README.md
+++ b/test/viewport/README.md
@@ -77,7 +77,7 @@ the internet, so the app's latency probes cannot reach anything real. The
harness answers them itself from a fixed delay table, with a deterministic
fraction failed outright, so the rows render a realistic spread of one-, two-
and three-digit latencies plus some unreachable rows. That spread is what the
-layout has to survive; 24 identical `---` placeholders would not exercise it.
+layout has to survive; a `---` placeholder in every row would not exercise it.
## What this cannot verify
@@ -104,10 +104,11 @@ Everything else this issue was actually about — does the layout reflow, does
anything overflow, is content clipped, are the controls big enough — is a
function of viewport width and CSS, and is covered above.
-## Relation to the unit test framework (#21)
+## Relation to the unit tests
-Complementary layers, not two stacks. `vitest` (#21) will exercise module-level
-logic in-process with no browser. This harness exercises rendered layout in a
-real engine and is the only thing here that can see a media query. Neither
-replaces the other; assertions about computed styles and element geometry belong
-here, assertions about functions belong in `vitest`.
+Complementary layers, not two stacks. The unit tests in `test/unit/`, which
+`make test` runs with Node's built-in test runner, exercise the functions
+`src/main.js` exports in-process with no browser. This harness exercises
+rendered layout in a real engine and is the only thing here that can see a media
+query. Neither replaces the other; assertions about computed styles and element
+geometry belong here, assertions about functions belong in `test/unit/`.
--
2.54.0
From 186f932eb8478b157e61bf8b30baada0ebdaf96f Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Sun, 4 Oct 2026 07:38:06 +0200
Subject: [PATCH 22/62] Footer no longer says IPv4 only (closes #111)
Each check is a fetch: the browser picks IPv4 or IPv6 for each WAN
host, and the local targets are IPv4 addresses, so "IPv4 only" was
wrong for the page as a whole. The footer's first line drops it and
its separator; the rest of the footer is unchanged. TODO.md moves this
to Completed Steps and the layout decision from Future Steps into Next
Step.
Model: opus-5-5
---
TODO.md | 12 +++++++-----
src/main.js | 2 +-
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/TODO.md b/TODO.md
index eea61eb..91c6e04 100644
--- a/TODO.md
+++ b/TODO.md
@@ -16,11 +16,16 @@ Docker image, and the Gitea workflow `.gitea/workflows/check.yml` runs
# Next Step
-Take "IPv4 only" out of the page's footer, as nothing in the page limits a check
-to IPv4 ([#111](https://git.eeqj.de/sneak/netwatch/issues/111)).
+Decide whether the repo moves to the layout `REPO_POLICIES.md` gives, with
+`backend/` no longer repeating files from the root
+([#30](https://git.eeqj.de/sneak/netwatch/issues/30)).
# Completed Steps
+- 2026-10-04: the page's footer no longer says "IPv4 only"
+ ([#111](https://git.eeqj.de/sneak/netwatch/issues/111)): each check is a
+ `fetch`, the browser picks IPv4 or IPv6 for each WAN host, and the local
+ targets are IPv4 addresses. The rest of the footer is unchanged
- 2026-10-04: `README.md`, `TODO.md` and `test/viewport/README.md` say what the
tree does (issue #24). The README's Getting Started leads with `make` targets;
a new Backend section says what `netwatch-server` stores, its routes and how
@@ -384,9 +389,6 @@ to IPv4 ([#111](https://git.eeqj.de/sneak/netwatch/issues/111)).
# Future Steps
-- Decide whether the repo moves to the layout `REPO_POLICIES.md` gives, with
- `backend/` no longer repeating files from the root
- ([#30](https://git.eeqj.de/sneak/netwatch/issues/30))
- Run `make frontend-viewport-test` in CI as its own step; it is not part of
`make check`, as it needs Docker and takes minutes
- A backend test that posts a report to `POST /api/v1/reports` and checks the
diff --git a/src/main.js b/src/main.js
index 630c96b..03eead7 100644
--- a/src/main.js
+++ b/src/main.js
@@ -857,7 +857,7 @@ function buildUI(state) {