5 Commits
Author SHA1 Message Date
sneak 5e572ce7f8 next → main: data directory safe as root, version from git, 80% target timeout, report retention (#85)
check / check (push) Canceled after 0s
Reviewed-on: #85
2026-10-26 13:10:15 +01:00
sneak 437e0ea80c Merge branch 'main' into next
check / check (push) Canceled after 0s
2026-10-26 13:09:53 +01:00
clawbot 0d08a75793 README.md rewritten sentence by sentence (closes #189)
check / check (push) Canceled after 0s
README.md is rewritten from scratch so that every sentence reads on the first pass, and is cut from about 5,600 words to about 3,500. It leads with what netwatch is, what it is for and how to run it, then the page, reporting and deployment, then every setting in a reference section, with design and development last. Mechanism detail the code and its comments already hold is left out; the PR lists each topic dropped and why. TODO.md records the rewrite of the three documents.

Model: fable-5-1 (rewrite, review); opus-5-5 (merge)
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-10-26 08:12:59 +01:00
clawbot 8ce69b7b40 test/viewport/README.md rewritten sentence by sentence
check / check (push) Canceled after 0s
test/viewport/README.md is rewritten from scratch so that every sentence reads on the first pass. It says what the layout test checks, at which widths, how to run it and what it cannot cover, and drops the history of how the test came about. Part of #189.

Model: fable-5-1 (rewrite, review); opus-5-5 (merge)
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-10-26 06:40:20 +01:00
sneak 871b986978 next into main (#73)
check / check (push) Successful in 2m3s
Reviewed-on: #73
2026-09-29 12:04:30 +02:00
2 changed files with 320 additions and 528 deletions
+197 -437
View File
@@ -36,23 +36,22 @@ make docker
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
`make check` and `make docker` need Docker. `make dev` serves the page at the `make check` and `make docker` need Docker. `make run` starts `netwatch-server`
address Vite prints and passes `/api` requests to `http://127.0.0.1:8080`. on port 8080 with its defaults, so the page's reports are stored in
`make run` starts `netwatch-server` there with its defaults, so the page's `data/reports`. `make dev` passes the page's `/api` requests there.
reports are stored in `data/reports`.
## The page ## The page
The page shows one row per host. A row has the host's name, its URL, its latest The page shows one row per host: the host's name, its URL, its latest latency in
latency in milliseconds and a graph of its last 100 checks. Below the figure, milliseconds and a graph of its last 100 checks. Below the figure are the
the row shows the minimum, median, average and maximum of the host's answers in minimum, median, average and maximum of the host's answers in that history, or
that history. After a failed check, it shows `timeout` or `unreachable` there `timeout` or `unreachable` after a failed check. A round of checks runs every 3
instead. A round of checks runs every 3 seconds by default. The header shows the seconds by default. The header shows the interval, the history's span, and
interval, how long the history spans, and whether the page is running or paused. whether the page is running or paused.
### Targets ### Targets
The page checks 26 hosts on the internet, called the WAN hosts: The page checks 26 hosts on the internet, the WAN hosts:
- `datavi.be`, pinned to the top at start - `datavi.be`, pinned to the top at start
- the Anthropic API and the OpenAI API - the Anthropic API and the OpenAI API
@@ -63,79 +62,32 @@ The page checks 26 hosts on the internet, called the WAN hosts:
- 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn, - 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn,
Hillsboro and Singapore Hillsboro and Singapore
Two local targets follow under the heading "Local Network". They are left out of Two local targets follow under the heading "Local Network", left out of the
the summary and the health box. summary and the health box. `Local CPE` is the cable modem at
`http://192.168.100.1`. `Local Gateway` is the first of `http://192.168.1.1`,
`http://192.168.0.1`, `http://192.168.8.1` and `http://10.0.0.1` to answer
within 1.5 seconds of the page loading; with no answer, it has no row. Browsers
block a page served from a public address from reaching private addresses. So
the local targets answer only when the page is served from `localhost` or from a
private address.
- `Local CPE` is the cable modem at `http://192.168.100.1`. Its row is always The host list is written in `web/src/main.js`; there is no setting for it.
shown.
- `Local Gateway` is found when the page loads. The page sends a request to
`http://192.168.1.1`, `http://192.168.0.1`, `http://192.168.8.1` and
`http://10.0.0.1`. The first to answer within 1.5 seconds becomes this target.
If none answers, there is no row for it.
Browsers block a page served from a public address from reaching private
addresses (Private Network Access). So the local targets answer only when the
page is served from `localhost` or from a private address, and only on a network
that has them.
The host list is written in `web/src/main.js`. There is no setting for it.
### What a figure measures ### What a figure measures
A check is a request to the target's URL: GET, or HEAD for the S3 regions. The A check is a request to the target's URL: `GET`, or `HEAD` for the S3 regions.
figure is the time from sending the request until the response headers arrive, The figure is the time from sending the request until the response headers
in milliseconds. Unlike ping, it includes the time the server takes to answer. arrive, in milliseconds. Unlike ping, it includes the time the server takes to
When the browser opens a new connection, it also includes the DNS lookup, the answer. On a new connection it also includes the DNS lookup, the connection
connection setup and the TLS handshake. Chrome opens a new connection for every setup and the TLS handshake. Any answer counts as reachable, an error page
check of the Hetzner servers, and for more targets at longer intervals. included. The browser follows redirects, so a target whose home page redirects
is checked at `/robots.txt` on the same host.
Any answer counts as reachable, an error page included. The request is made in A check times out after 80% of the interval: 2.4 seconds at the default. A check
the browser's `no-cors` mode, so the page cannot read the answer, only time it. that answers after that limit counts as a timeout too. A check that fails with a
network error is recorded as unreachable. The first round after the page loads
The browser follows redirects, and the figure then includes the request to the is discarded, as its figures include the connection setup. So is the first round
new URL too. So a target whose home page redirects is checked at `/robots.txt` after an interval change.
on the same host, which answers without a redirect. The S3 regions are checked
with a HEAD request. Chrome keeps the connection to an S3 region open after a
HEAD request, but closes it after most GET requests.
The request is sent with `cache: 'no-store'`, so the browser's cache stays out
of the measurement. It carries no `Referer` header and no query string: the
Hetzner servers close the connection without an answer when either is present.
A check times out after 80% of the interval: 2.4 seconds at the default of 3
seconds, 24 seconds at 30 seconds. So a round's checks have all finished before
the next round is due. A check that times out is recorded as a timeout, and so
is one that answers after that limit. A check that fails with a network error is
recorded as unreachable.
A round can start early, after an interval change or when the recovery probe
finds a target answering. It then gives up the last round's checks that are
still waiting, so rounds never overlap.
The first round after the page loads is discarded, as is the first round after
an interval change. Its connections are all new, so its figures run high.
The browser chooses between IPv4 and IPv6 for each target, as for any request.
The local targets are IPv4 addresses.
### Colors
A latency figure and its line in the graph take one color:
- green: under 50 ms
- lime: under 100 ms
- yellow: under 200 ms
- orange: under 500 ms
- red: 500 ms and over
- gray: no answer
### The graph
The graph in each row plots the host's last 100 checks. At the default interval
that is 5 minutes; the labels on its time axis say how long. The vertical axis
runs from 0 to 1000 ms. A latency above 1000 ms is drawn at the top, and the
figure still shows its real value. The rounds in which a check failed are shaded
red.
### Health and summary ### Health and summary
@@ -147,156 +99,95 @@ counts as failed when its last check timed out or found it unreachable.
- `SLOW` (yellow): otherwise, more than 3 hosts took over 1000 ms - `SLOW` (yellow): otherwise, more than 3 hosts took over 1000 ms
- `HEALTHY` (green): otherwise - `HEALTHY` (green): otherwise
Below the health box, a summary line covers the WAN hosts only: Below it, a summary line covers the WAN hosts only:
- `Reachable`: how many answered in the last round, out of 26 - `Reachable`: how many of the 26 answered their last check
- `Now`: the minimum, median, average and maximum of their latest figures - `Now`: the minimum, median, average and maximum of their latest figures
- the history's span, such as `5m`, with the minimum and maximum over the whole - the history's span, such as `5m`: the minimum and maximum over the whole
history history
- `Checks`: the number of rounds run since the page loaded or the interval - `Checks`: the rounds run since the page loaded or the interval changed
changed
When no WAN host answers, the page starts a recovery probe. Every half second it When no WAN host answers, the page checks 4 of them, picked at random, every
checks 4 WAN hosts and gives up the checks it started half a second before. The half second. As soon as one answers, a new round starts.
4 are picked at random when the probe starts. As soon as one answers, a new
round starts at once.
### Row order ### Rows, colors and controls
The WAN rows are sorted by their latest latency, fastest first and unreachable A figure and its line in the graph take the color of the legend in the footer,
last. The rows are sorted when the second round ends, and again in rounds 11, from green under 50 ms to red from 500 ms up; gray is no answer. The graph's
21, 31 and so on. The pin button at the left of a row keeps the row at the top. axis ends at 1000 ms; a higher latency is drawn at the top, and the figure still
Pinned rows are sorted by name, and a line separates them from the rest. shows its real value.
`datavi.be` is pinned at start.
### Controls From time to time, the page sorts the WAN rows by their latest latency: fastest
first, failed last. The pin button at the left of a row keeps the row at the
top; pinned rows are sorted by name. `datavi.be` is pinned at start.
- `Pause` stops the checks. The graphs keep scrolling and leave a gap for each `Pause` stops the checks: the graphs keep scrolling with a gap for each paused
paused round; no outage is drawn. The figures turn gray until `Resume`. round, and the figures turn gray. The `Interval` menu sets the time between
- The `Interval` menu sets the time between rounds: 1, 2, 3, 5, 10, 15, 30 or 60 rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the history and
seconds. Changing it clears the history on the page and starts a new round at starts a new round at once. The `Debug log` checkbox in the footer opens a panel
once. with the page's last 1000 log lines.
- The pin button in each WAN row pins and unpins the row.
- Each host's URL is a link that opens in a new tab.
- The `Debug log` checkbox in the footer opens a panel with the page's last 1000
log lines. Each line has a level: error, warning, notice, info or debug.
- The header shows the local time with its time zone, and the UTC time. The
footer shows the color legend and links to the commit the page was built from.
When the page records a failed check, it writes one line to the browser console
with `console.error`, and the same line to the debug log. Nothing is written for
the discarded first round, for a check a round gives up, or by the recovery
probe. The line gives the target's name and URL, the time, what failed and how
long the request took. The three kinds of failure are a timeout, an answer over
the time limit, and a network error. For a network error the line quotes the
error the browser gives the page, such as `TypeError: Failed to fetch`, which
does not say why. When a target answers again after failed checks, one
`console.info` line gives its latency and how many checks in a row had failed.
### Browsers
The page needs a browser with ES modules, the Fetch API, the Canvas API and CSS
custom properties.
## Reporting ## Reporting
Every 60 seconds the page gathers its undelivered samples and posts them as a Every 60 seconds the page posts its undelivered samples as a report to
report to `/api/v1/reports` on the origin it was served from. When there are `/api/v1/reports` on the origin it was served from. The report is JSON with four
none, it posts nothing. The report is JSON with four fields: fields:
- `clientId`: a random id, made once per browser and kept in `localStorage`. - `clientId`: a random id, made once per browser and kept in `localStorage`, or
When the browser has no usable storage, a new id is made for the session. once per session when the browser has no usable storage
- `geo`: always `null` - `geo`: always `null`
- `hosts`: one entry per host with new samples. The entry has the host's `name`, - `hosts`: one entry per host with new samples: its `name`, `url` and `status`,
`url` and `status`, and `history`, a list of samples. A sample has the check's and `history`, its samples. A sample has the check's time `t` in Unix
time `t` in Unix milliseconds, its `latency` (`null` for a failed check) and milliseconds, its `latency` (`null` for a failed check) and its `error`
its `error` (`"timeout"`, `"unreachable"` or `null`). (`"timeout"`, `"unreachable"` or `null`).
- `timestamp`: the time of the report - `timestamp`: the time of the report
Each report carries only the samples no earlier report has delivered. For each Each report carries only the samples no earlier report has delivered. A sample
host, the page remembers the time of the newest sample it delivered, and moves waits as long as the history holds it: the last 100 rounds of each host. An
that mark only when the server answers 2xx. Paused rounds are never sent. While interval change clears the history. The cleared samples are kept, the last 100
the page is paused, no report is sent at all. One report is in flight at a time. of each host, until a report delivers them. Nothing is sent while the page is
A report with no answer after 30 seconds is abandoned, and its samples go in the paused. A report with no answer after 30 seconds is abandoned, and its samples
next report. So a server that stored them but answered late gets them twice. go in the next one. So a server that stored them but answered late gets them
twice.
A failed delivery is quiet. The page writes one debug log line when deliveries A failed delivery is quiet, as on a static file host with no backend: one debug
start failing and one when they recover. The checks go on. log line when deliveries start failing, one when they recover. The checks go on.
A sample waits for delivery only as long as the history holds it. The history
keeps each host's last 100 rounds: 5 minutes at the default interval of 3
seconds. At 1 second it spans 100 seconds, at 2 seconds 200 seconds, and at 60
seconds 100 minutes. Changing the interval clears the history. The cleared
samples are kept apart, the last 100 per host, and those not yet delivered go in
the next report.
So at the 1-second interval, one refused or failed report loses the oldest 20
seconds of each host's samples: the next delivered report comes 2 minutes after
the last. At 2 seconds and longer, one failed report loses nothing. Samples are
lost from the third failed report in a row at 2 seconds, from the fifth at 3
seconds, and later still at longer intervals.
Each paused round still adds an entry to the history, so each report due during
a pause counts as a failed one. A pause longer than the history spans loses the
samples recorded between the last delivered report and the pause: up to a
minute's worth for each host.
## Deployment ## Deployment
### Static files `make docker` builds the image `netwatch` from `Dockerfile`: the whole service
in one container. nginx serves the page and passes `/api/`,
`/.well-known/healthcheck` and `/metrics` to `netwatch-server` in the same
container. The backend listens on `127.0.0.1:8081` only, as user `netwatch` (uid
1000). `bin/entrypoint.sh` starts both and watches them.
`make build` writes the page to `dist/`: an `index.html` and an `assets/` Before the backend starts, `bin/entrypoint.sh` runs two subcommands of
directory with one CSS file and one JavaScript file. Any static file host can `netwatch-server`. `check-cidr` checks each `TRUSTED_PROXIES` entry (see
serve it. Without a backend at `/api/v1/reports` on that host, the reports fail [Running under upaas](#running-under-upaas)). `prepare-data-dir` creates the
quietly and nothing is stored. report directory and gives `/data`, with the directories, regular files and
symbolic links in it, to user `netwatch`. It changes nothing outside `/data`.
### The Docker image The image also:
`make docker` builds the image `netwatch` from `Dockerfile`. It is the whole - takes the client address from `X-Forwarded-For` only on a request from a proxy
service in one container. nginx serves the page and passes `/api/`, named in `TRUSTED_PROXIES`, and passes that one address to the backend. The
`/.well-known/healthcheck` and `/metrics` to `netwatch-server`, which runs in backend is started with `TRUSTED_PROXIES=127.0.0.1/32`, so it trusts nginx
the same container. The backend listens on `127.0.0.1:8081` only, as user alone.
`netwatch` (uid 1000). `bin/entrypoint.sh` starts both and watches them. - writes nginx's access log and the backend's JSON log to stdout
- refuses a request body over 1 MiB with nginx's own 413 error page
The image:
- listens on port `8080`; the `PORT` variable changes it (see
[Settings](#settings))
- stores the report files in `/data/reports`, on the `/data` volume. Before the
backend starts, `netwatch-server prepare-data-dir` creates that directory and
gives `/data` and the directories, regular files and symbolic links in it to
user `netwatch`. A file with a second name (hard link) keeps its owner, and
its path is logged. A host directory mounted at `/data` therefore ends up
owned by uid 1000. The command changes nothing outside `/data`, whatever links
it meets there.
- takes the client address from `X-Forwarded-For` only on a request from a
reverse proxy named in `TRUSTED_PROXIES`. nginx resolves the address and
passes that one address to the backend. The backend is started with
`TRUSTED_PROXIES=127.0.0.1/32`, so it trusts nginx alone.
- writes nginx's access log to stdout, and the backend's log as JSON to stdout
- sends the page with `Cache-Control: no-cache`, so a browser that keeps a copy
asks the server whether it is current before using it again. The files under
`/assets/` are cached as immutable; their names change whenever their contents
do.
- sends the security headers in `configs/security-headers.conf` on every
response, in place of the backend's own
- refuses a request body over 1 MiB with nginx's own 413 error page; the backend
never sees such a request
- requests `/.well-known/healthcheck` through nginx every 30 seconds, as its - requests `/.well-known/healthcheck` through nginx every 30 seconds, as its
`HEALTHCHECK`. The check fails unless both nginx and the backend answer. It `HEALTHCHECK`. The check fails unless both nginx and the backend answer, and
also fails while report files cannot be written. Three failures in a row make while report files cannot be written.
the container `unhealthy`; one pass makes it `healthy` again. - on `docker stop`, writes the waiting reports to disk and exits 0, or non-zero
- on `docker stop`, writes the waiting reports to disk and exits 0. If that if that write fails. If nginx or the backend exits on its own, the other is
final write fails, the container exits non-zero. If nginx or the backend exits stopped and the container exits non-zero.
on its own, the other is stopped and the container exits non-zero, so the
platform restarts it.
### Running under upaas ### Running under upaas
The [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs: The [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
- Port: container port `8080`. - Port: container port `8080`.
- Volume: container path `/data`. The reports are kept in `/data/reports`. - Volume: container path `/data`.
- Environment variables: none is required. Every setting in - Environment variables: none is required. Every setting in
[Settings](#settings) applies, with four differences in the image: [Settings](#settings) applies, with four differences in the image:
- `BIND_ADDRESS` has no effect. The backend listens on `127.0.0.1` inside - `BIND_ADDRESS` has no effect. The backend listens on `127.0.0.1` inside
@@ -307,14 +198,16 @@ The [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
`.` or `..` part and no extra `/`. A path through a symbolic link is `.` or `..` part and no extra `/`. A path through a symbolic link is
refused unless the link is relative and stays inside `/data`. refused unless the link is relative and stays inside `/data`.
- `TRUSTED_PROXIES` names the proxies nginx trusts, as IP addresses or CIDRs - `TRUSTED_PROXIES` names the proxies nginx trusts, as IP addresses or CIDRs
separated by commas. The backend's own setting is fixed to `127.0.0.1/32`. separated by commas; the backend's own setting is fixed to `127.0.0.1/32`.
With `TRUSTED_PROXIES` set and empty, nginx trusts no proxy. Every client `bin/entrypoint.sh` gives a lone address a `/32` or `/128` and checks each
behind the proxy then shares the proxy's one allowance of entry with `netwatch-server check-cidr`, which refuses what the backend
`REPORTS_PER_MINUTE`. Any client that connects from an address in the set would refuse. Set and empty, nginx trusts no proxy, and every client
can write its own `X-Forwarded-For`. A connection through a port Docker behind a proxy then shares the proxy's one allowance of
publishes can arrive from the Docker network's gateway, such as `REPORTS_PER_MINUTE`. A client that connects from a trusted address can
`172.17.0.1`. The default includes that address. Where that matters, name write its own `X-Forwarded-For`. A port Docker publishes can bring
only the addresses that nothing but the proxy connects from. connections from the Docker network's gateway, such as `172.17.0.1`, which
the default includes. Where that matters, name only the proxy's own
addresses.
- Health: the image's `HEALTHCHECK`, described above. - Health: the image's `HEALTHCHECK`, described above.
The container stops at start when a variable holds a value it cannot use. The The container stops at start when a variable holds a value it cannot use. The
@@ -350,30 +243,24 @@ names the variable. An empty variable counts as unset, except `TRUSTED_PROXIES`.
API, separated by commas. See [CORS](#cors). API, separated by commas. See [CORS](#cors).
- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: the basic auth - `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: the basic auth
credentials for `/metrics`. See [Metrics](#metrics). credentials for `/metrics`. See [Metrics](#metrics).
- `SENTRY_DSN`, default empty: the DSN of a Sentry project to report crashes to. - `SENTRY_DSN`, default empty: the DSN of a Sentry project. Each request whose
See [Sentry](#sentry). handling crashes is reported there; the request still gets a 500.
- `DEBUG`, default empty: any non-empty value turns debug logging on, `false` - `DEBUG`, default empty: any non-empty value turns debug logging on, `false`
and `0` included. No value of it stops the start. and `0` included. Every value is accepted.
### Trusted proxies ### Trusted proxies
Each `TRUSTED_PROXIES` entry must be a CIDR, such as `127.0.0.1/32`; a lone Each `TRUSTED_PROXIES` entry must be a CIDR, such as `127.0.0.1/32`; a lone
address is refused. A list replaces the default. Set and empty, the server address is refused. A list replaces the default; set and empty, the server
trusts no proxy. Loopback is not in the default, so a reverse proxy on the same trusts no proxy. Loopback is not in the default, so a reverse proxy on the same
host has to be named. host has to be named.
A request from a peer outside this set has its forwarded headers ignored. The A request from a peer outside this set has its forwarded headers ignored; the
peer's own address is logged and rate limited. peer's own address is logged and rate limited. From a peer inside the set, the
client address is the right-most `X-Forwarded-For` entry that is not in the set.
A request from a peer inside the set takes its client address from The peer's address is used when every entry is in the set, or when the search
`X-Forwarded-For`. Each proxy appends the address it took the request from, so from the right meets an entry that is not an address, such as `unknown`.
the entries on the right were written by trusted proxies and the ones further `X-Real-IP` is read only when `X-Forwarded-For` is missing or empty.
left by the client. The client address is the right-most entry that is not in
the set. An entry may carry a port, such as `198.51.100.7:5000` or
`[2001:db8::1]:443`. It is read as the address alone. The peer's address is used
when every entry is in the set. It is also used when the search from the right
meets an entry that is not an address at all, such as `unknown`. `X-Real-IP` is
read only when `X-Forwarded-For` is missing or empty.
### Routes ### Routes
@@ -391,29 +278,14 @@ Every request body is capped at 1 MiB, and every request at 60 seconds.
### Report storage ### Report storage
The server keeps the reports it takes in memory and writes them to `DATA_DIR` as The server keeps the reports it takes in memory and writes them to `DATA_DIR` as
files of JSON lines, compressed with zstd. It writes the waiting reports to a files of JSON lines, compressed with zstd. It writes a new file every minute,
new file every minute, whenever 10 MiB of them are waiting, and when it stops. whenever 10 MiB of reports are waiting, and when it stops. With none waiting, it
With none waiting, it writes no file. writes no file. The files are named `reports-<timestamp>-<number>.jsonl.zst`;
the timestamp is UTC to the millisecond, so the names sort by time.
The files are named `reports-<timestamp>-<number>.jsonl.zst`. The timestamp is The reports of a failed write, as on a full disk, stay in memory for the next
UTC to the millisecond, so the names sort by time. The number starts at 1 when write to try again. Until a write succeeds, `POST /api/v1/reports` refuses every
the server starts, and goes up by one for each file the server starts to write. report with 500 and the health check answers 503.
So two files started in the same millisecond still get different names. A file
is created with `O_EXCL`, so it never overwrites another.
A write can fail, as when the disk is full. The reports of a failed write stay
in memory. The next write tries them again, within a minute. One file is written
at a time, so a file holds its reports in the order they arrived, those of a
failed write included. A failed write uses up its number. A file it created is
removed. If removing it fails too, the file stays and counts toward
`DATA_DIR_MAX_BYTES` from the next start.
From a failed write until a write succeeds, `POST /api/v1/reports` refuses every
report with 500 and keeps nothing of it, and the health check answers 503. The
page sends the refused samples again in its next report, as long as it still
holds them (see [Reporting](#reporting)). Reports already answered 200 are lost
only if the server stops before a write succeeds. The final write at stop tries
them too, and if it fails, the process exits non-zero.
### Report limits ### Report limits
@@ -421,101 +293,48 @@ them too, and if it fails, the process exits non-zero.
bounded in four ways. Each refusal by the backend answers with the body bounded in four ways. Each refusal by the backend answers with the body
`{"status":"error"}`. `{"status":"error"}`.
- JSON only. A report must be sent with `Content-Type: application/json`, with - JSON only. A report must be sent with `Content-Type: application/json`; any
or without parameters such as `charset`, as the page sends it. Any other other content type is refused with 415, and malformed JSON with 400.
content type is refused with 415, and nothing of it is stored. A page on - Body limit. A report body may be at most 1 MiB. A request that declares a
another origin can send a `text/plain` POST, or a form, without a CORS longer body, or a report that runs past that, is refused with 413.
preflight. Refusing those keeps any site from making its visitors' browsers
store reports (see [CORS](#cors)).
- Body limit. A report body may be at most 1 MiB, the backend's limit for any
request. A request that declares a longer body is answered 413 before it
reaches the route. A body sent without a declared length is read up to 1 MiB.
The route answers 413 only when the report runs past that; nothing after the
end of the report is read. A body that stops being valid JSON sooner is
answered 400. In the image, nginx refuses a body over 1 MiB itself, with its
own HTML error page, and the backend never sees the request.
- Rate limit. Each client address may send `REPORTS_PER_MINUTE` reports a - Rate limit. Each client address may send `REPORTS_PER_MINUTE` reports a
minute. [Trusted proxies](#trusted-proxies) says how that address is found. minute; past that it gets 429 with `Retry-After: 60`. The page sends one
Past that it gets 429 with `Retry-After: 60`. The minute slides: reports from report a minute from each open tab, so the default of 60 refuses nothing from
the minute before still count, fading out over the current one. An address up to 30 tabs behind one address.
that sends at most half the limit in any 60 seconds is never refused. Above
that, it may be. The page sends one report a minute from each open tab. So the
default of 60 refuses nothing from up to 30 tabs behind one address, however
their reports bunch up. Every answer the route gives carries the headers
`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.
- Size cap. The report files in `DATA_DIR` may total at most - Size cap. The report files in `DATA_DIR` may total at most
`DATA_DIR_MAX_BYTES`, counting the files already there at start. Reports `DATA_DIR_MAX_BYTES`, counting the files already there at start and the
waiting in memory count at their uncompressed size until they are written. reports waiting in memory. When a report would take the total past the cap,
When a report would take the total past the cap, the oldest report files are the oldest files are deleted to make room. A report is refused with 507 when
deleted to make room, and each deletion is logged with the file's name and deleting the oldest files cannot make room.
size. 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;
then no file is deleted. At start, report files past the cap are deleted the
same way, as after lowering it.
So the cap decides how much of the newest reports is kept. The default of 1 GiB At the defaults, one address can fill the 1 GiB cap with its own reports in
fits on any host; set it to the space you can give `DATA_DIR`. At the defaults, about 17 minutes, and every report stored before them is deleted to make room.
one address may send 60 reports of up to 1 MiB a minute. A report made up to
compress badly takes about as much room in the files as in its request body. So
in about 17 minutes one address can fill the 1 GiB cap with its own reports, and
every report stored before them is deleted to make room. A lower
`REPORTS_PER_MINUTE` or a higher `DATA_DIR_MAX_BYTES` makes that take longer.
### CORS ### CORS
The page calls the API from the origin it is served from. By default the server The page needs no entry here: it calls the API from the origin it is served
sends no CORS headers, so browsers let no other origin's pages call it. To serve from. By default the server sends no CORS headers, and browsers let no other
the page from elsewhere, list that origin in `CORS_ALLOWED_ORIGINS`, for example origin's pages call it. `CORS_ALLOWED_ORIGINS` lists other origins, such as
`https://netwatch.example.com`. Pages from a listed origin may `GET` and `POST` `https://netwatch.example.com`, whose pages may then call the API with `GET` and
with a `Content-Type` header. `POST`. Each entry must be a plain origin as browsers send it: `scheme://host`,
with an optional `:port`, no path, no trailing `/` and no `*`.
Each entry must be a plain origin as browsers send it: `scheme://host`, with an
optional `:port`, no path, no trailing `/` and no `*`. Any other entry stops the
server from starting.
### Metrics ### Metrics
With both `METRICS_USERNAME` and `METRICS_PASSWORD` set, the server serves With both `METRICS_USERNAME` and `METRICS_PASSWORD` set, the server serves
Prometheus metrics at `GET /metrics`. A request with those as its basic auth Prometheus metrics at `GET /metrics`. A request with those as its basic auth
credentials gets them; any other gets 401. With neither set, there are no credentials gets them; any other gets 401. With neither set, `/metrics`
metrics and `/metrics` answers 404. One set without the other stops the start, answers 404. The metrics record each request that reaches the health check or
and so does a user name containing `:`. `POST /api/v1/reports`: its duration and response size, labelled with its path,
method and status. Each client address may make 60 requests to `/metrics` a
The metrics record each request that reaches the health check or minute. Past that it gets 429 with `Retry-After: 60`, and its credentials are
`POST /api/v1/reports`. For each, they record its duration and response size, not checked.
labelled with its path, method and status, and they count such requests in
progress. Requests the rate limit refuses are included. Go's runtime and process
metrics are included too. No other request is recorded:
- not a request to `/metrics` itself
- not a CORS preflight
- not a 404 for an unknown path, or a 405 for the wrong method
- not a 413 answered before the route
Only those two routes are recorded because the labels are the request's path and
method. Recording every request would let clients add labels without bound.
`/metrics` has its own rate limit, so that its password cannot be guessed
quickly. Each client address may make 60 requests to it a minute, whatever its
credentials. The address is found as for reports. Past that it gets 429 with
`Retry-After: 60`, and its credentials are not checked. The minute slides as it
does for reports, so a scraper polling every 2 seconds or less often is never
refused.
### Sentry
With `SENTRY_DSN` set, each request whose handling crashes is reported to that
Sentry project, under the release `netwatch-server-` followed by the server's
version. The request still gets a 500 answer. The server does not start if
Sentry rejects the DSN. On shutdown the server waits up to 2 seconds for Sentry
to finish sending. Empty, the server sends nothing to Sentry.
### Logging ### Logging
The backend logs JSON to stdout, or plain text when stdout is a terminal. Each The backend logs JSON to stdout, or plain text when stdout is a terminal. Each
line names the source location it was written from. Each request is logged with request is logged with its method, URL, status, client address and duration.
its method, URL, status, client address and duration, among other fields. Every Every value taken from a request is cut to 128 bytes before it is logged.
value taken from a request is cut to 128 bytes before it is logged.
## Design ## Design
@@ -537,24 +356,17 @@ The repository holds the page, the backend and the image:
The page is built with Vite and Tailwind CSS v4. All its code is in The page is built with Vite and Tailwind CSS v4. All its code is in
`web/src/main.js`: `web/src/main.js`:
- `CONFIG` holds the timing and display constants. The interval menu sets its - `CONFIG`: the timing and display constants. The interval menu sets its
`updateInterval`; the check timeout, the history span and the time axis are `updateInterval`; the check timeout and the history span follow from it.
computed from it. - `WAN_HOSTS`: the WAN targets, each with its URL and, for the S3 entries, the
- `WAN_HOSTS` is the list of WAN targets, each with its URL. The S3 entries also method `HEAD`.
name HEAD as their method. - `HostState` and `AppState`: one host's history and status, and the whole
- `HostState` is one host's history, latest latency and status. page's state.
- `AppState` holds the WAN hosts, the local hosts, the pause state and the round - `measureLatency`: one check. `tick`: one round, which checks every host in
count, and computes the summary and the health. parallel and draws each row as soon as its check ends.
- `measureLatency` runs one check. - `SparklineRenderer`: the graphs. `buildUI`, `updateHostRow`, `updateSummary`
- `tick` runs one round. It checks every host in parallel and draws each row as and `updateHealthBox`: the page and its parts.
soon as its check ends. When the last check ends, it redraws every row, the - `Reporter`: builds and posts the reports. `buildReport` is a pure function of
summary and the health box, and sorts the rows when a sort is due. A discarded
round draws nothing.
- `SparklineRenderer` draws a graph on a canvas, scaled to the device's pixel
ratio.
- `buildUI` builds the page; `updateHostRow`, `updateSummary` and
`updateHealthBox` redraw its parts.
- `Reporter` builds and posts the reports. `buildReport` is a pure function of
the hosts' state. the hosts' state.
### The backend ### The backend
@@ -562,24 +374,17 @@ The page is built with Vite and Tailwind CSS v4. All its code is in
`netwatch-server` is a Go HTTP server. The `fx` library starts and stops its `netwatch-server` is a Go HTTP server. The `fx` library starts and stops its
parts in dependency order. The backend's packages are in `internal/`: parts in dependency order. The backend's packages are in `internal/`:
- `cli` runs the command line. Given no arguments, it runs the server. It also - `cli`: the command line: the server when given no arguments, or the
runs the `check-cidr` and `prepare-data-dir` subcommands, which `check-cidr` and `prepare-data-dir` subcommands that `bin/entrypoint.sh` runs.
`bin/entrypoint.sh` runs before the server starts. Any other arguments get a - `config`: reads the settings and refuses values the server cannot use.
usage message and exit code 2. - `server`: the router, the routes and the HTTP server.
- `config` reads the settings and refuses values the server cannot use. - `middleware`: the request log, the security headers, CORS, the body limit, the
- `server` builds the router and the HTTP server and registers the routes. rate limit, the client address resolution and the metrics.
- `middleware` holds the request log, the security headers, CORS, the body - `handlers`: the health check and the report route.
limit, the rate limit, the client address resolution and the metrics. - `reportbuf`: the in-memory buffer of reports and the files it writes them to.
- `handlers` answers the health check and takes reports. It also holds `PrepareDataDir`, which `prepare-data-dir` runs.
- `reportbuf` is the in-memory buffer of reports and the files it writes them - `healthcheck`, `logger` and `globals`: the health status, the logger, and the
to. It also holds `PrepareDataDir`, which `prepare-data-dir` runs. name and version the build stamps in.
- `healthcheck`, `logger` and `globals` hold the health status, the logger and
the name and version the build stamps in.
In the image, the `test` phase of `Dockerfile` tests the backend, the `builder`
stage builds it, and `bin/entrypoint.sh` runs it as user `netwatch` on
`127.0.0.1:8081`, behind nginx. Outside the image, `make run` builds it and runs
it on port 8080.
## Development ## Development
@@ -588,100 +393,55 @@ it on port 8080.
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard. The scripts in `script/` are the entrypoints for the development standard. The scripts in `script/` are the entrypoints for the development
workflow, and the `Makefile` targets are thin shims that call them. The scripts workflow, and the `Makefile` targets are thin shims that call them.
cover both the page and the Go backend, so `make check` fails if either one is
broken. Every script that runs yarn on the host does so through `script/yarn`,
except `script/bootstrap`.
- `script/bootstrap`: install the dependencies. It uses the `node` on `PATH` - `script/bootstrap`: install the dependencies. It installs the pinned node,
when that node has the pinned major version and is new enough for the page's yarn and Go when the ones on `PATH` do not pass its checks, and links them
dependencies. Otherwise it installs nvm from a hash-verified archive, and the into `~/.local/bin`, which has to be on `PATH`. It installs no Go linter and
pinned node through it. It uses the `yarn` on `PATH` only when it is exactly no Docker.
the pinned version. Otherwise it installs that version through corepack, or - `script/setup`: `script/bootstrap`, then the git pre-commit hook.
through npm where there is no corepack. Then it runs - `script/dev`: run the Vite dev server.
`yarn install --frozen-lockfile`. It uses the `go` on `PATH` only when it is
exactly the pinned version. Otherwise it installs that release from a
hash-verified archive. Then it runs `go mod download`. What it installs itself
is linked into `~/.local/bin`, which has to be on `PATH`. It installs no Go
linter and no Docker; `make test` and `make lint` run in Docker.
- `script/setup`: make a fresh clone ready for development: `script/bootstrap`,
then the git pre-commit hook.
- `script/dev`: run the Vite dev server, which passes `/api` to a
`netwatch-server` running locally.
- `script/build`: build the page into `dist/`, then the static `netwatch-server` - `script/build`: build the page into `dist/`, then the static `netwatch-server`
binary into the repo root. The binary's version is `VERSION` from the binary into the repo root. Its version is `VERSION` from the environment, else
environment. When that is unset or empty, it is `git describe --tags --always --dirty`, else `dev`.
`git describe --tags --always --dirty`, as in `script/docker`, or `dev`
outside a git checkout.
- `script/run`: `script/build`, then run `netwatch-server` with its defaults. - `script/run`: `script/build`, then run `netwatch-server` with its defaults.
- `script/clean`: remove what `script/build` writes. - `script/clean`: remove what `script/build` writes.
- `script/projectname`: print the project name, `netwatch`, which - `script/projectname`: print `netwatch`, the image tag `script/docker` uses.
`script/docker` uses as the image tag. - `script/yarn`: run yarn under the node `script/bootstrap` would use.
- `script/yarn`: run yarn with the given arguments under the node - `script/test`: build the `test` phase of `Dockerfile`, writing no image: the
`script/bootstrap` would use. Go tests with the race detector and coverage, and the page's unit tests.
- `script/test`: build the `test` phase of `Dockerfile`, writing no image. The - `script/lint`: build the `lint` phase of `Dockerfile`, writing no image:
phase runs the backend's Go tests with the race detector and coverage, and the golangci-lint over the Go packages and eslint over the JavaScript.
page's unit tests in its `frontend-test` stage. - `script/fmt`: run the formatters: prettier over the JavaScript, CSS, HTML and
- `script/lint`: build the `lint` phase of `Dockerfile`, writing no image. The Markdown, then gofmt over `cmd/` and `internal/`. They write the files in
phase runs golangci-lint over the Go packages, and eslint in its place.
`frontend-lint` stage.
- `script/fmt`: run the formatters, which write: prettier over the JavaScript,
CSS, HTML and Markdown, then gofmt over `cmd/` and `internal/`. It runs on the
host, with `~/.local/bin` put on `PATH`.
- `script/fmt-check`: check the formatting (read-only), with the same scope as - `script/fmt-check`: check the formatting (read-only), with the same scope as
`script/fmt`. `script/fmt`.
- `script/check`: `script/test`, `script/lint` and `script/fmt-check`. - `script/check`: `script/test`, `script/lint` and `script/fmt-check`.
- `script/add-dependency`: add a page dependency, or move one to another - `script/add-dependency`: add a page dependency, or move one to another
version. `make add-dependency PACKAGE=<name>@<version>` runs `yarn add --dev`, version: `make add-dependency PACKAGE=<name>@<version>`.
which changes `package.json` and `yarn.lock` together, then - `script/tidy`: run `go mod tidy`.
`yarn install --frozen-lockfile`.
- `script/tidy`: run `go mod tidy`. To add a Go module, import it and run
`make tidy`. To move one to another version, edit its `require` line in
`go.mod`, then run `make tidy`.
- `script/frontend-test`: run the unit tests in `test/unit/` on the host, with - `script/frontend-test`: run the unit tests in `test/unit/` on the host, with
Node's built-in test runner, then the production build. If a test fails, the Node's built-in test runner, then the production build.
tests run again listing every test, and the script fails. Each test run has a
90-second timeout.
- `script/frontend-fmt` and `script/frontend-fmt-check`: the prettier half of - `script/frontend-fmt` and `script/frontend-fmt-check`: the prettier half of
`script/fmt` and `script/fmt-check`. `script/fmt` and `script/fmt-check`.
- `script/frontend-viewport-test`: check the built page's layout in a - `script/frontend-viewport-test`: check the built page's layout in a
containerised headless Chrome; see containerised headless Chrome, not as part of `script/check`; see
[test/viewport/README.md](test/viewport/README.md). It is not part of [test/viewport/README.md](test/viewport/README.md).
`script/check`, as it takes minutes.
- `script/docker`: build the image from `Dockerfile`, tagged `netwatch`. - `script/docker`: build the image from `Dockerfile`, tagged `netwatch`.
- `script/cibuild`: the CI entrypoint. It runs `script/bootstrap` and - `script/cibuild`: `script/bootstrap`, `script/check`, then the image build.
`script/check`, then builds the image as `script/docker` does. The Gitea The Gitea workflow `.gitea/workflows/check.yml` runs it on every push.
workflow `.gitea/workflows/check.yml` runs it on every push.
- `script/precommit`: run by the git pre-commit hook; it runs `script/check`. - `script/precommit`: run by the git pre-commit hook; it runs `script/check`.
- `script/install-precommit`: install the git pre-commit hook. `make hooks` - `script/install-precommit`: install the git pre-commit hook. `make hooks`
calls it. calls it.
### Build caching
`script/test`, `script/lint`, `script/docker` and `script/cibuild` each pass a
new `CACHEBUST` build argument. In `Dockerfile`, every step that lints, tests or
compiles comes after `ARG CACHEBUST`, so it runs again on every build, even when
nothing has changed. Docker keeps the layers above that line, the system
packages and the Go modules, until the base image, their own lines, or `go.mod`
and `go.sum` change. Go's build cache and golangci-lint's cache are kept between
builds too; each reuses a result only when everything it was made from is
unchanged. Both `go test` runs carry `-count=1`, so no test result is reused.
The yarn packages are installed again on every build.
### Tests ### Tests
`make test` runs the backend's Go tests and the page's unit tests in `make test` runs the backend's Go tests and the page's unit tests in Docker. The
`test/unit/`, in Docker. The unit tests import `web/src/main.js` in Node and unit tests import `web/src/main.js` in Node and call the functions it exports;
call the functions it exports; no browser is involved. no browser is involved. `make frontend-viewport-test` checks the built page's
layout in a headless Chrome; [test/viewport/README.md](test/viewport/README.md)
`make frontend-viewport-test` builds the page and checks its layout at several says what it checks and what it cannot.
window widths in a headless Chrome. The page is served with the nginx image and
the `configs/nginx.conf` that the Docker image ships. It tests each breakpoint
declared in `web/src/styles.css`, today the one at `max-width: 768px`, and the
widths one pixel either side of it. It also tests four fixed widths: 320px,
1280px and two phone landscape sizes.
[test/viewport/README.md](test/viewport/README.md) says what it checks and what
it cannot.
## TODO ## TODO
+123 -91
View File
@@ -1,114 +1,146 @@
# Responsive-layout harness # The viewport test
Automated verification of the responsive layout that landed in #5. Run it with: `make frontend-viewport-test` checks the page's layout at several window widths
in a real browser:
```bash ```bash
make frontend-viewport-test make frontend-viewport-test
``` ```
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the First it runs the unit tests. Then it builds the page into `dist/`. It serves
same `configs/nginx.conf` the shipping container uses, drives a digest-pinned `dist/` with the nginx image and the `configs/nginx.conf` that the Docker image
headless Chrome against it over CDP, and asserts on computed layout at every ships. Before the browser starts, it fetches `/` and `/no-such-page`, which
viewport width derived from the app's own CSS. Screenshots land in nginx answers with the page. Both must carry `Cache-Control: no-cache` and a
`tmp/viewport/` alongside a `results.json`; they are artifacts for a human to `Content-Security-Policy` header. Then it starts a headless Chrome in a
look at when something fails, not the evidence. The assertions are the evidence. container and opens the page in it at each width. For each width it measures the
layout inside the page and checks the measurements in Node. The checks decide
pass or fail. Screenshots and a `results.json` land in `tmp/viewport/`, for a
person to look at when a check fails.
The target is deliberately outside `make check`: it takes minutes, and The test is not part of `make check`: it takes minutes, and `make test` has to
`make test` has to stay under 60 seconds. finish in under 60 seconds.
## How the widths are chosen ## The widths
Not from a list of phone models. `viewports.js` parses the `@media` conditions The breakpoints are read from the page's own files. `viewports.js` reads the
out of `web/src/styles.css` and scans `web/src/main.js` and `web/index.html` for `min-width` and `max-width` conditions out of the `@media` blocks in
Tailwind responsive prefixes, then tests every breakpoint it finds at one pixel `web/src/styles.css`. It also looks in `web/src/main.js` and `web/index.html`
below it, exactly on it, and one pixel above it. A generic 375px "phone" test for Tailwind's responsive class prefixes, such as `md:`, and takes their
sails straight past an off-by-one at a media query boundary; `max-width: 768px` breakpoints. Each breakpoint is tested at one pixel below it, exactly on it, and
matches _at_ 768, and the sweep pins down which side of that line each layout is one pixel above it. Today the CSS has one breakpoint, `max-width: 768px`, and no
on. responsive prefix is used.
Nothing hardcodes 768. Add a second media block or start using `md:` classes and A `max-width: 768px` block matches at 768 pixels. Testing both sides of each
the new breakpoint is covered without this directory being touched. The app is breakpoint catches a layout that switches one pixel too early or too late. A
desktop-first today (all narrow rules live in `max-width` blocks); a second media block, or a first `md:` class, is covered without a change to this
`min-width`-only, mobile-first set is handled as its inverse, and a set that directory.
mixes the two makes the run fail loudly rather than test the right widths with
the wrong expectation. Four further viewports are fixed anchors, each with a
stated reason: a 320px floor, a 1280px desktop baseline, and two phone-landscape
sizes straddling the breakpoint for the rotation case.
## What it asserts The page is desktop-first: the wide layout is the default, and every narrow rule
is in a `max-width` block. So a width is expected to show the narrow layout when
one of those blocks matches it. A page with only `min-width` blocks would be
read the other way round. A page that mixes the two kinds fails the run with a
message, because the right expectation for each width can no longer be read from
the breakpoints alone.
- **app-rendered** — enough host rows exist and enough of them show a numeric Four more widths are fixed, each for a stated reason:
latency. This one exists so the rest cannot pass vacuously against a blank
page.
- **no-horizontal-overflow** — `documentElement.scrollWidth` fits the layout
viewport, with the widest offending element named.
- **nothing-past-viewport-edge** — no visible element's box extends past the
viewport edge.
- **no-clipped-text** — nothing hides text behind `overflow: hidden`. Deliberate
ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is
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: 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
the full row width. A row that merely shrank its 420px column would fail.
- **probing-still-runs / gateway-detection-still-runs** — narrow viewports keep
probing and keep detecting the gateway. The mobile early-return path proposed
in #8 was rejected; this is what would catch it coming back.
### The tap-target threshold - 320 by 568 pixels: the narrowest width still in common use
- 667 by 375 pixels: a phone in landscape, inside the narrow layout
- 844 by 390 pixels: a large phone in landscape, in the wide layout but still a
touch device
- 1280 by 800 pixels: a desktop window
44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in Every width but 1280 by 800 is tested as a touch device.
WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". WCAG 2.2 SC 2.5.8 (level AA) sets a
lower 24x24 floor, but that floor comes with a spacing exception these controls
do not qualify for — the pin buttons sit directly against the host name they
belong to.
## Determinism ## The checks
The browser container runs on an `--internal` docker network and has no route to A failed check does not stop the others.
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; a `---` placeholder in every row would not exercise it.
## What this cannot verify - `app-rendered`: at least 10 WAN host rows exist, and at least 5 rows show a
number as their latency. Without this, the other checks could pass against a
blank page.
- `no-horizontal-overflow`: the document is no wider than the window. On a
failure, the widest content is named.
- `nothing-past-viewport-edge`: no visible element extends past the window's
edge.
- `no-clipped-text`: no element hides its text behind `overflow: hidden` or
`overflow: clip`. Text cut with an ellipsis is allowed: that cut is a design
choice, not a defect. Tailwind's `truncate` cuts host names and URLs that way.
- `tap-targets-44px`, on touch widths: each measured control is at least 44 by
44 CSS pixels. The controls are `#pause-btn`, `#interval-select`,
`#debug-toggle` and the `.pin-btn` in each WAN host row. The check also
demands that each selector matches enough visible elements: one of each single
control, and one pin button per WAN host row. A renamed class would otherwise
take its controls out of the measured set, and the check would pass on
nothing.
- `host-rows-stacked` or `host-rows-side-by-side`: each host row shows the
layout expected at that width. In the narrow layout, the row's
`flex-direction` is `column`, the graph sits below the host's details, and
each takes at least 90% of the row's width. In the wide layout, the direction
is `row` and the graph sits to the right of the details. A row that only
shrank its fixed 420-pixel column fails.
- `probing-still-runs`: the page sent at least one check to a target at this
width. The narrow layout must not skip the checks.
- `gateway-detection-still-runs`: the `Local Gateway` row is present at this
width, so the gateway was looked for and found. The narrow layout must not
skip that search.
Real limits, so nobody re-parks this issue as needing hardware: ### The tap-target size
- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be 44 by 44 CSS pixels is the figure in Apple's Human Interface Guidelines and in
emulated by it; Safari-specific bugs (viewport units under a collapsing URL WCAG 2.2 success criterion 2.5.5, "Target Size (Enhanced)". WCAG 2.2 criterion
bar, `-webkit-fill-available`, form control metrics) will not show up here. 2.5.8 sets a lower minimum of 24 by 24 pixels, with an exception that depends on
- **Real touch input.** `hasTouch` emulation changes what the page is told, not the space around a control. The pin buttons sit against the host name they
how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and belong to, so the test keeps 44.
hover-state fallbacks on touch are out of scope.
- **Physical pixel density and rendering.** `deviceScaleFactor` is set, but
subpixel antialiasing, OLED colour rendering and actual legibility at a given
physical size are not measurable here.
- **Fonts.** The container has DejaVu, not the platform's own UI monospace. Text
metrics are therefore close to, but not identical to, a real device — a layout
that fits here by a few pixels might not there.
- **On-device performance.** Canvas sparkline redraw cost, battery, and
behaviour on a slow radio are not measured.
- **Browser chrome.** The address bar, safe-area insets and notch cutouts are
not simulated.
Everything else this issue was actually about — does the layout reflow, does ## The browser's network
anything overflow, is content clipped, are the controls big enough — is a
function of viewport width and CSS, and is covered above. The browser container runs on an internal Docker network with no route to the
internet, so the page's checks reach no real host. The test answers them itself
from a fixed table of delays: 2, 45, 123, 456 and 780 milliseconds. Which delay
a target gets depends on its URL, and about one target in seven fails outright.
So the rows show one-, two- and three-digit latencies and some unreachable rows,
and the layout is tested against that spread. The request to
`http://192.168.1.1` is answered after 5 milliseconds, so the `Local Gateway`
row appears.
At each width, the test sets the interval to 1 second and waits until at least 5
rows show a latency before it measures. The page discards its first round, so
this reaches a filled page without waiting out two rounds at 3 seconds.
## What the test cannot check
- Other browser engines. The test runs Chrome. Safari uses WebKit, and bugs that
only WebKit has do not show up here.
- Real touch input. Touch emulation changes what the page is told, not how a
finger behaves. Gestures, scroll momentum, double-tap zoom and hover states on
touch are not tested.
- Physical pixels. The device pixel ratio is set, but antialiasing, colour
rendering and legibility at a given physical size are not measured.
- Fonts. The container has DejaVu, not a phone's own monospace font. Text is
about as wide as on a real device, but not exactly. A layout that fits here by
a few pixels might not fit there.
- Performance. The cost of redrawing the graphs, battery use and behaviour on a
slow connection are not measured.
- The browser's own frame. The address bar, safe-area insets and notch cutouts
are not simulated.
## The files
- `viewports.js` derives the widths from the sources.
- `facts.js` is run inside the page and measures the layout. It only measures;
it decides nothing.
- `checks.js` turns the measurements into the pass or fail of each check, with
the numbers that decided it.
- `harness.js` connects to the browser, runs each width, writes the screenshots
and `results.json`, and prints the results. `script/frontend-viewport-test`
starts it; every input it needs arrives in the environment.
## Relation to the unit tests ## Relation to the unit tests
Complementary layers, not two stacks. The unit tests in `test/unit/`, which The unit tests in `test/unit/` call the functions that `web/src/main.js`
`make test` runs with Node's built-in test runner, exercise the functions exports, with no browser. `make test` runs them with Node's built-in test
`web/src/main.js` exports in-process with no browser. This harness exercises runner. This test runs the built page in a real browser engine and is the only
rendered layout in a real engine and is the only thing here that can see a media test here that can see a media query. Neither replaces the other. Checks on
query. Neither replaces the other; assertions about computed styles and element computed styles and element geometry belong here; checks on functions belong in
geometry belong here, assertions about functions belong in `test/unit/`. `test/unit/`.