1 Commits
Author SHA1 Message Date
clawbot 87a2305048 Target names, URLs and log lines reach the page as text (closes #29)
check / check (push) Successful in 3m29s
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 named <b>x</b>;
hostRowHTML is exported for it.

README.md stops calling CONFIG frozen: the interval menu changes
updateInterval. 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
2026-10-04 02:01:52 +00:00
13 changed files with 42 additions and 107 deletions
+1 -2
View File
@@ -1,7 +1,6 @@
backend/
dist/ dist/
node_modules/ node_modules/
tmp/ tmp/
yarn.lock yarn.lock
.claude/ .claude/
# The org standard file, copied verbatim; backend/script/lint checks its sha256.
backend/.golangci.yml
+3 -7
View File
@@ -1,5 +1,5 @@
.PHONY: bootstrap setup dev build test lint fmt fmt-check check \ .PHONY: bootstrap setup dev test lint fmt fmt-check check frontend-check \
frontend-check frontend-viewport-test docker hooks frontend-viewport-test docker hooks
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -13,11 +13,7 @@ setup:
@script/setup @script/setup
dev: dev:
@script/dev yarn dev
# The frontend only; backend/Makefile's build target builds the Go server.
build:
@script/build
test: test:
@script/test @script/test
+3 -9
View File
@@ -46,10 +46,6 @@ halves, so the root `make check` fails if either one is broken. We provide:
both linters in Docker both linters in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the - `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook git pre-commit hook
- `script/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/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run `script/frontend-test`, then `backend/script/test`, the - `script/test` — run `script/frontend-test`, then `backend/script/test`, the
backend's Go tests with the race detector and coverage backend's Go tests with the race detector and coverage
@@ -65,8 +61,7 @@ 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 - `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint` runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
builds builds
- `script/frontend-fmt` — format everything prettier understands (writes), the - `script/frontend-fmt` — format everything prettier understands (writes)
markdown in `backend/` included
- `script/frontend-fmt-check` — check prettier formatting (read-only) - `script/frontend-fmt-check` — check prettier formatting (read-only)
- `script/frontend-check` — run `script/frontend-test` and - `script/frontend-check` — run `script/frontend-test` and
`script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has `script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has
@@ -105,9 +100,8 @@ 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: code lives in `src/main.js` with a class-based architecture:
- **`CONFIG`**: Configuration object (update interval, timeouts, axis ticks, - **`CONFIG`**: Configuration object (update interval, timeouts, axis ticks,
etc.). The interval menu sets `updateInterval`, the one value the page writes etc.). `updateInterval` is the one value in it that changes while the page
into `CONFIG`; the timeouts, the time the history spans and the x-axis ticks runs: the interval menu sets it
are computed from it
- **`HostState`**: Per-host state management — history buffer, latency tracking, - **`HostState`**: Per-host state management — history buffer, latency tracking,
status transitions status transitions
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause - **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
+5 -16
View File
@@ -26,22 +26,11 @@ latest run passes.
- 2026-10-04: a target's name and URL and a debug log message show as the - 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 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 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 `<`, `>`, `"`, as text. A unit test checks a target named `<b>x</b>`. `README.md` no longer
`&` and `'`. `README.md` no longer calls `CONFIG` frozen: the interval menu calls `CONFIG` frozen; the interval menu changes its `updateInterval`.
sets its `updateInterval`, and the values computed from it follow. `AppState` `AppState` declares the recovery probe's two properties, the sparkline axis
declares the recovery probe's two properties, the sparkline axis functions functions lose the parameters they did not use, and the comment on a target's
lose the parameters they did not use, and the comment on a target's history history names both kinds of entry it holds. Nothing the page does changed
names both kinds of entry it holds. Nothing the page does changed
- 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 - 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 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 deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never
+18 -18
View File
@@ -32,16 +32,17 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
`test`, `fmt` and `fmt-check`: `test`, `fmt` and `fmt-check`:
- `script/build` — compile the static `netwatch-server` binary with its version - `script/build` — compile the static `netwatch-server` binary with its version
stamped in. The version is `VERSION` from the environment; when that is unset stamped in. The version is `VERSION` from the environment;
or empty, it falls back to `git describe` inside a git checkout, then to `dev` 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 - `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 `-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 again with `-v` for the details, and the script fails. The race detector needs
a C compiler a C compiler
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run - `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 golangci-lint. It runs inside the golangci-lint image of the lint stage of
root `Dockerfile`; from a checkout, run `make lint` at the repo root, which the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
builds that stage which builds that stage
- `script/fmt` — format the Go sources (writes) - `script/fmt` — format the Go sources (writes)
- `script/fmt-check` — check Go formatting (read-only) - `script/fmt-check` — check Go formatting (read-only)
- `script/run` — build and run the server locally - `script/run` — build and run the server locally
@@ -60,9 +61,8 @@ flushes them to compressed files on disk for later analysis.
## Design ## Design
The server is structured as an `fx`-wired Go application under The server is structured as an `fx`-wired Go application under `cmd/netwatch-server/`.
`cmd/netwatch-server/`. Internal packages in `internal/` follow standard Go Internal packages in `internal/` follow standard Go project layout:
project layout:
- **`config`**: Loads configuration from environment variables and config files - **`config`**: Loads configuration from environment variables and config files
via Viper. via Viper.
@@ -89,12 +89,11 @@ project layout:
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) | | `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) | | `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
`TRUSTED_PROXIES` defaults to `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`.
`127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. The loopback The loopback entries cover a reverse proxy on the same host. A request whose
entries cover a reverse proxy on the same host. A request whose direct peer is direct peer is outside this set has its forwarded headers ignored, and the
outside this set has its forwarded headers ignored, and the direct peer is direct peer is logged and rate-limited instead. The container image does not use
logged and rate-limited instead. The container image does not use this default; this default; see [Container image](#container-image).
see [Container image](#container-image).
A variable set to a value the server cannot use, such as `PORT=abc`, 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 `DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
@@ -110,9 +109,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 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 the `/data` volume; before starting the server, the entrypoint creates it and
gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`, gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
which acts on nothing outside `/data`. nginx replaces the security headers this which acts on nothing outside `/data`. nginx replaces the security headers
server sets with those in the root `security-headers.conf`, so those are what this server sets with those in the root `security-headers.conf`, so those are
clients of the image see. what clients of the image see.
The container's own `TRUSTED_PROXIES` goes to nginx instead: IP addresses or 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. CIDRs, separated by commas, of the reverse proxies in front of the container.
@@ -163,7 +162,8 @@ 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 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 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 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 ### CORS
+1 -3
View File
@@ -17,13 +17,11 @@
# #
# golangci-lint is not installed: make lint runs it in Docker, which # golangci-lint is not installed: make lint runs it in Docker, which
# this script does not install either. # this script does not install either.
#
# Unlike the org model: Go and gcc for backend/, a newer node for eslint.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06 # Pinned versions, 2026-07-07
NODE_VERSION="22.17.0" NODE_VERSION="22.17.0"
# The oldest node the frontend's dependencies accept: the "engines" # 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 # field of eslint 10.12.0, the most demanding of them, asks for 22.13.0
-13
View File
@@ -1,13 +0,0 @@
#!/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 "$@"
-13
View File
@@ -1,13 +0,0 @@
#!/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 "$@"
-1
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/fmt: format the whole repo (writes): prettier over everything # script/fmt: format the whole repo (writes): prettier over everything
# it understands, then gofmt over the Go backend. # it understands, then gofmt over the Go backend.
# The org model formats only markdown; this repo also has JS and Go.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
-1
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting across the whole repo (read-only). # script/fmt-check: check formatting across the whole repo (read-only).
# Same scope as script/fmt, but fails instead of writing. # 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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# script/frontend-fmt: format the frontend and every other file prettier # script/frontend-fmt: format the frontend and every other file prettier
# understands, repo-wide (writes), the markdown in backend/ included. # understands, repo-wide (writes). backend/ is in .prettierignore; Go
# Prettier does not read Go; backend/script/fmt formats the Go sources. # sources are formatted by backend/script/fmt.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+2 -2
View File
@@ -8,8 +8,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
hook=".git/hooks/pre-commit" hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x .git/hooks/pre-commit chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit" echo "pre-commit hook installed: runs script/precommit"
} }
+7 -20
View File
@@ -231,30 +231,17 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
} }
}); });
// The page shows &lt; &gt; &quot; &amp; and &#39; in a row's markup as // The page shows &lt; in a row's markup as < and &gt; as >.
// < > " & and '. test("a target named <b>x</b> shows those characters in its row, as does its URL", () => {
test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {
const host = new HostState({ const host = new HostState({
name: `<b>"x" & 'y'</b>`, name: "<b>x</b>",
url: `https://x.test/<b>?a="x"&b='y'`, url: "https://x.test/<b>x</b>",
}); });
const row = hostRowHTML(host, 0); const row = hostRowHTML(host, 0);
assert.doesNotMatch(row, /<b>/); assert.doesNotMatch(row, /<b>/);
assert.ok( assert.ok(row.includes(">&lt;b&gt;x&lt;/b&gt;</span>"));
row.includes( assert.ok(row.includes('href="https://x.test/&lt;b&gt;x&lt;/b&gt;"'));
">&lt;b&gt;&quot;x&quot; &amp; &#39;y&#39;&lt;/b&gt;</span>", assert.ok(row.includes(">https://x.test/&lt;b&gt;x&lt;/b&gt;</a>"));
),
);
assert.ok(
row.includes(
'href="https://x.test/&lt;b&gt;?a=&quot;x&quot;&amp;b=&#39;y&#39;"',
),
);
assert.ok(
row.includes(
">https://x.test/&lt;b&gt;?a=&quot;x&quot;&amp;b=&#39;y&#39;</a>",
),
);
}); });
for (const [seconds, text] of [ for (const [seconds, text] of [