clawbot a3b18b6ce8
check / check (push) Canceled after 0s
README.md: rework against the second review
The four findings: only a recorded failed check is logged, and the
discarded round, given-up checks and the recovery probe log nothing;
prepare-data-dir gives directories, regular files and symbolic links to
the netwatch user, and a hard link keeps its owner and is logged; the
cli entry names what it runs in three short sentences; the 80% timeout
and the early-round rule have their reasons back, so rounds never
overlap.

The full pass also fixed claims broader than the code (a row always
shows its stats; a report is posted every 60 seconds; a file is written
every minute; each refusal answers JSON; every answer to a report
carries rate-limit headers; every setting applies in the image, where
BIND_ADDRESS has no effect; fmt formats all files), six sentences whose
subject was split from its verb (the recovery probe, the forwarded
entry with a port, the failed write's retry and its file, both rate
limits, the nvm archive), and the viewport test's "same ... as"
comparison.

Model: fable-5-1
2026-10-26 04:41:53 +00:00

NetWatch is an MIT-licensed single-page web application by @sneak that shows the latency from your browser to 26 well-known internet hosts. The page is JavaScript and does all its measuring in the browser. A small Go backend, netwatch-server, stores the measurements the page reports. The page and the backend ship together in one Docker image.

Rationale

When a network misbehaves, it helps to see at a glance which hosts answer and how fast. NetWatch is one page with a latency figure and a small graph for each host, refreshed every few seconds. The page measures from the browser, so it shows the path your own traffic takes. It can be served from any static file host. The backend only stores what the page reports. Without it, the page works the same and nothing is stored.

Getting Started

# Install the dependencies and the git pre-commit hook
make setup

# Serve the page from the Vite dev server
make dev

# Build the page and netwatch-server, then run the server on port 8080
make run

# Run the tests, both linters and the format check
make check

# Build the page into dist/ and netwatch-server into the repo root
make build

# Build the Docker image and run it
make docker
docker run -p 8080:8080 netwatch

make check and make docker need Docker. make dev serves the page at the address Vite prints and passes /api requests to http://127.0.0.1:8080. make run starts netwatch-server there with its defaults, so the page's reports are stored in data/reports.

The page

The page shows one row per host. A row has the host's name, its URL, its latest latency in milliseconds and a graph of its last 100 checks. Below the figure, the row shows the minimum, median, average and maximum of the host's answers in that history. After a failed check, it shows timeout or unreachable there instead. A round of checks runs every 3 seconds by default. The header shows the interval, how long the history spans, and whether the page is running or paused.

Targets

The page checks 26 hosts on the internet, called the WAN hosts:

  • datavi.be, pinned to the top at start
  • the Anthropic API and the OpenAI API
  • the AWS Console, the Google Cloud Console and Microsoft Azure
  • Cloudflare, Fastly, Akamai, Google, GitHub and Backblaze B2
  • 8 S3 regional endpoints: Cape Town, London, Bahrain, Tokyo, Singapore, Sydney, Oregon and São Paulo
  • 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn, Hillsboro and Singapore

Two local targets follow under the heading "Local Network". They are left out of the summary and the health box.

  • Local CPE is the cable modem at http://192.168.100.1. Its row is always 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

A check is a request to the target's URL: GET, or HEAD for the S3 regions. The figure is the time from sending the request until the response headers arrive, in milliseconds. Unlike ping, it includes the time the server takes to answer. When the browser opens a new connection, it also includes the DNS lookup, the connection setup and the TLS handshake. Chrome opens a new connection for every check of the Hetzner servers, and for more targets at longer intervals.

Any answer counts as reachable, an error page included. The request is made in the browser's no-cors mode, so the page cannot read the answer, only time it.

The browser follows redirects, and the figure then includes the request to the new URL too. So a target whose home page redirects is checked at /robots.txt 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, or answers after that limit, is recorded as a timeout. 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

Above the rows, a health box sums up the WAN hosts' latest results. A host counts as failed when its last check timed out or found it unreachable.

  • OFFLINE (red): more than 10 hosts failed and at most 4 answered
  • DEGRADED (orange): otherwise, more than 4 hosts failed
  • SLOW (yellow): otherwise, more than 3 hosts took over 1000 ms
  • HEALTHY (green): otherwise

Below the health box, a summary line covers the WAN hosts only:

  • Reachable: how many answered in the last round, out of 26
  • 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 history
  • Checks: the number of rounds run since the page loaded or the interval changed

When no WAN host answers, the page starts a recovery probe. Every half second it checks 4 WAN hosts and gives up the checks it started half a second before. The 4 are picked at random when the probe starts. As soon as one answers, a new round starts at once.

Row order

The WAN rows are sorted by their latest latency, fastest first and unreachable last. The rows are sorted when the second round ends, and again in rounds 11, 21, 31 and so on. The pin button at the left of a row keeps the row at the top. Pinned rows are sorted by name, and a line separates them from the rest. datavi.be is pinned at start.

Controls

  • Pause stops the checks. The graphs keep scrolling and leave a gap for each paused round; no outage is drawn. The figures turn gray until Resume.
  • The Interval menu sets the time between rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the history on the page and starts a new round at once.
  • 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.

Each failed check the page records writes one line to the browser console with console.error, and the same line to the debug log. The discarded first round, the checks a round gives up and the recovery probe write none. 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

Every 60 seconds the page gathers its undelivered samples and posts them as a report to /api/v1/reports on the origin it was served from. When there are none, it posts nothing. The report is JSON with four fields:

  • clientId: a random id, made once per browser and kept in localStorage. When the browser has no usable storage, a new id is made for the session.
  • geo: always null
  • hosts: one entry per host with new samples. The entry has the host's name, url and status, and history, a list of samples. A sample has the check's time t in Unix milliseconds, its latency (null for a failed check) and its error ("timeout", "unreachable" or null).
  • timestamp: the time of the report

Each report carries only the samples no earlier report has delivered. For each host, the page remembers the time of the newest sample it delivered, and moves that mark only when the server answers 2xx. Paused rounds are never sent. While the page is paused, no report is sent at all. One report is in flight at a time. A report with no answer after 30 seconds is abandoned, and its samples go in the next report. A server that stored them but answered late therefore gets them twice.

A failed delivery is quiet. The page writes one debug log line when deliveries start failing and 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, but the cleared samples not yet delivered are kept apart, the last 100 per host, until they are delivered.

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

Static files

make build writes the page to dist/: an index.html and an assets/ directory with one CSS file and one JavaScript file. Any static file host can serve it. Without a backend at /api/v1/reports on that host, the reports fail quietly and nothing is stored.

The Docker image

make docker builds the image netwatch from Dockerfile. It is the whole service in one container. nginx serves the page and passes /api/, /.well-known/healthcheck and /metrics to netwatch-server, which runs 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.

The image:

  • listens on port 8080; the PORT variable changes it (see 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 HEALTHCHECK. The check fails unless both nginx and the backend answer, and while report files cannot be written. Three failures in a row make the container unhealthy; one pass makes it healthy again.
  • on docker stop, writes the waiting reports to disk and exits 0. If nginx or the backend exits on its own, the other is stopped and the container exits non-zero, so the platform restarts it.

Running under upaas

The upaas app for netwatch needs:

  • Port: container port 8080.
  • Volume: container path /data. The reports are kept in /data/reports.
  • Environment variables: none is required. Every setting in Settings applies, with four differences in the image:
    • BIND_ADDRESS has no effect. The backend listens on 127.0.0.1 inside the container.
    • PORT is nginx's public port, from 1 to 65535 and not 8081, where the backend listens inside the container.
    • DATA_DIR must be /data or a path below it, written in full, with no . or .. part and no extra /. A path through a symbolic link that leads out of /data is refused too.
    • 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. With TRUSTED_PROXIES set and empty, nginx trusts no proxy. Every client behind the proxy then shares the proxy's one allowance of REPORTS_PER_MINUTE. Any client that connects from an address in the set can write its own X-Forwarded-For. A connection through a port Docker publishes can arrive from the Docker network's gateway, such as 172.17.0.1. The default includes that address. Where that matters, name only the addresses that nothing but the proxy connects from.
  • Health: the image's HEALTHCHECK, described above.

A variable set to a value the container cannot use stops it at start, with the reason in its log.

Settings

netwatch-server reads its settings from environment variables. It also reads a .env file in the directory it starts in, and a YAML file named netwatch-server.yaml in /etc/netwatch-server/ or ~/.config/netwatch-server/, when one exists. A variable in the environment wins over a file.

A variable set to a value the server cannot use stops it from starting, with an error naming the variable. An empty variable counts as unset, except TRUSTED_PROXIES.

  • BIND_ADDRESS, default empty: the IP address to listen on. Empty listens on every interface. The image sets it to 127.0.0.1.
  • PORT, default 8080: the port to listen on, from 1 to 65535. In the image this is nginx's port; the backend listens on 8081.
  • DATA_DIR, default ./data/reports (/data/reports in the image): the directory for the report files. The server creates it if it is missing, and stops at start if it cannot create a file in it.
  • DATA_DIR_MAX_BYTES, default 1073741824 (1 GiB): the most bytes of report files kept in DATA_DIR, oldest deleted first. A positive whole number. See Report limits.
  • REPORTS_PER_MINUTE, default 60: how many reports each client address may send a minute. A positive whole number. See Report limits.
  • TRUSTED_PROXIES, default 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16: the reverse proxies whose X-Forwarded-For and X-Real-IP headers are trusted, as CIDRs separated by commas. See Trusted proxies.
  • CORS_ALLOWED_ORIGINS, default empty: other origins whose pages may call the API, separated by commas. See CORS.
  • METRICS_USERNAME and METRICS_PASSWORD, default empty: the basic auth credentials for /metrics. See Metrics.
  • SENTRY_DSN, default empty: the DSN of a Sentry project to report crashes to. See Sentry.
  • DEBUG, default empty: any non-empty value turns debug logging on, false and 0 included. No value of it stops the start.

Trusted proxies

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 trusts no proxy. Loopback is not in the default, so a reverse proxy on the same host has to be named.

A request from a peer outside this set has its forwarded headers ignored. The peer's own address is logged and rate limited.

A request from a peer inside the set takes its client address from X-Forwarded-For. Each proxy appends the address it took the request from, so the entries on the right were written by trusted proxies and the ones further 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

  • POST /api/v1/reports takes a report from the page. It needs no credentials; Report limits bound it instead.
  • GET /.well-known/healthcheck answers 200 with a JSON object: appname, version, now, uptime_seconds, uptime_human and status, which is "ok". While report files cannot be written, it answers 503 with the same fields and "status":"error".
  • GET /metrics serves Prometheus metrics, only when METRICS_USERNAME and METRICS_PASSWORD are set. See Metrics.

Every request body is capped at 1 MiB, and every request at 60 seconds.

Report storage

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 new file every minute, whenever 10 MiB of them are waiting, and when it stops. With none waiting, it 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 number starts at 1 when the server starts, and goes up by one for each file the server starts to write. 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). 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

POST /api/v1/reports takes reports from anyone who can reach it, so it is bounded in four ways. Each refusal by the backend answers with the body {"status":"error"}.

  • JSON only. A report must be sent with Content-Type: application/json, with or without parameters such as charset, as the page sends it. Any other content type is refused with 415, and nothing of it is stored. A page on another origin can send a text/plain POST, or a form, without a CORS preflight. Refusing those keeps any site from making its visitors' browsers store reports (see 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 minute. Trusted proxies says how that address is found. Past that it gets 429 with Retry-After: 60. The minute slides: reports from the minute before still count, fading out over the current one. An 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 DATA_DIR_MAX_BYTES, counting the files already there at start. Reports waiting in memory count at their uncompressed size until they are written. 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 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 fits on any host; set it to the space you can give DATA_DIR. At the defaults, 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

The page calls the API from the origin it is served from. By default the server sends no CORS headers, so browsers let no other origin's pages call it. To serve the page from elsewhere, list that origin in CORS_ALLOWED_ORIGINS, for example https://netwatch.example.com. Pages from a listed origin may GET and POST with a Content-Type header.

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

With both METRICS_USERNAME and METRICS_PASSWORD set, the server serves 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 metrics and /metrics answers 404. One set without the other stops the start, and so does a user name containing :.

The metrics record each request that reaches the health check or POST /api/v1/reports. For each, they record its duration and response size, 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. A DSN Sentry does not accept stops the start. On shutdown the server waits up to 2 seconds for Sentry to finish sending. Empty, the server sends nothing to Sentry.

Logging

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 its method, URL, status, client address and duration, among other fields. Every value taken from a request is cut to 128 bytes before it is logged.

Design

The repository holds the page, the backend and the image:

  • web/: the page's source. index.html loads src/main.js and src/styles.css.
  • cmd/netwatch-server/ and internal/: the Go backend. cmd/ holds only main, which calls into internal/cli.
  • configs/: nginx's configuration for the image, nginx.conf and security-headers.conf.
  • bin/entrypoint.sh: the image's entrypoint.
  • Dockerfile: the lint and test phases, the two build stages and the image.
  • script/: the development commands; Makefile targets call them.
  • test/unit/: the page's unit tests. test/viewport/: the layout test.

The page

The page is built with Vite and Tailwind CSS v4. All its code is in web/src/main.js:

  • CONFIG holds the timing and display constants. The interval menu sets its updateInterval; the check timeout, the history span and the time axis are computed from it.
  • WAN_HOSTS is the list of WAN targets, with the URL and method each is checked with.
  • HostState is one host's history, latest latency and status.
  • AppState holds the WAN hosts, the local hosts, the pause state and the round count, and computes the summary and the health.
  • measureLatency runs one check.
  • tick runs one round. It checks every host in parallel and draws each row as soon as its check ends. When the last check ends, it redraws every row, the summary and the health box, and sorts the rows when a sort is due.
  • 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 backend

netwatch-server is a Go HTTP server wired with fx. Its packages are in internal/:

  • cli runs the command line. Given no arguments, it runs the server. It also runs the check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh runs before the server starts. Any other arguments get a usage message and exit code 2.
  • config reads the settings and refuses values the server cannot use.
  • server builds the router and the HTTP server and registers the routes.
  • middleware holds the request log, the security headers, CORS, the body limit, the rate limit, the client address resolution and the metrics.
  • handlers answers the health check and takes reports.
  • reportbuf is the in-memory buffer of reports and the files it writes them to. It also holds PrepareDataDir, which prepare-data-dir runs.
  • 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

Entrypoints

This repository adheres to the Scripts to Rule Them All standard. The scripts in script/ are the entrypoints for the development workflow, and the Makefile targets are thin shims that call them. The scripts 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 when that node has the pinned major version and is new enough for the page's dependencies. Otherwise it installs nvm from a hash-verified archive, and the pinned node through it. It uses the yarn on PATH only when it is exactly the pinned version. Otherwise it installs that version through corepack, or through npm where there is no corepack. Then it runs 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 binary into the repo root. The binary's version is VERSION from the environment. When that is unset or empty, it is 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/clean: remove what script/build writes.
  • script/projectname: print the project name, netwatch, which script/docker uses as the image tag.
  • script/yarn: run yarn with the given arguments under the node script/bootstrap would use.
  • script/test: build the test phase of Dockerfile, writing no image. The phase runs the backend's Go tests with the race detector and coverage, and the page's unit tests in its frontend-test stage.
  • script/lint: build the lint phase of Dockerfile, writing no image. The phase runs golangci-lint over the Go packages, and eslint in its 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.
  • script/check: script/test, script/lint and script/fmt-check.
  • script/add-dependency: add a page dependency, or move one to another version. make add-dependency PACKAGE=<name>@<version> runs yarn add --dev, which changes package.json and yarn.lock together, then 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 Node's built-in test runner, then the production build. If a test fails, the 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/fmt and script/fmt-check.
  • script/frontend-viewport-test: check the built page's layout in a containerised headless Chrome; see test/viewport/README.md. It is not part of script/check, as it takes minutes.
  • script/docker: build the image from Dockerfile, tagged netwatch.
  • script/cibuild: the CI entrypoint. It runs script/bootstrap and script/check, then builds the image as script/docker does. The Gitea workflow .gitea/workflows/check.yml runs it on every push.
  • script/precommit: run by the git pre-commit hook; it runs script/check.
  • script/install-precommit: install the git pre-commit hook. make hooks 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

make test runs the backend's Go tests and the page's unit tests in test/unit/, in Docker. The unit tests import web/src/main.js in Node and call the functions it exports; no browser is involved.

make frontend-viewport-test builds the page and checks its layout at several 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 says what it checks and what it cannot.

TODO

The to-do list is TODO.md: where the work stands, the next step, the open work, and what has been done.

License

MIT. See LICENSE.

Author

@sneak

S
Description
No description provided
Readme MIT
1.4 MiB
Languages
Go 49%
JavaScript 36.9%
Shell 10.4%
Dockerfile 2.2%
CSS 1%
Other 0.5%