Every sentence is under about 30 words, with one idea each. The page, its targets and what a figure measures come first, then reporting, deployment, the settings reference, the design, and development last. No tables; every identifier in backticks. The code on next decides what is true. Model: fable-5-1
33 KiB
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. 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 CPEis the cable modem athttp://192.168.100.1. Its row is always shown.Local Gatewayis found when the page loads. The page sends a request tohttp://192.168.1.1,http://192.168.0.1,http://192.168.8.1andhttp://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
Each check is a GET request to the target's URL. 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. 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.
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 answeredDEGRADED(orange): otherwise, more than 4 hosts failedSLOW(yellow): otherwise, more than 3 hosts took over 1000 msHEALTHY(green): otherwise
Below the health box, a summary line covers the WAN hosts only:
Reachable: how many answered in the last round, out of 26Now: 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, picked at random when the probe starts, and gives up the checks it started half a second before. 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, then every 10 rounds. 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
Pausestops the checks. The graphs keep scrolling and leave a gap for each paused round; no outage is drawn. The figures turn gray untilResume.- The
Intervalmenu 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 logcheckbox 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 check that fails writes one line to the browser console with
console.error, and the same line to the debug log. 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 posts a report to /api/v1/reports on the origin it
was served from. The report is JSON with four fields:
clientId: a random id, made once per browser and kept inlocalStorage. When the browser has no usable storage, a new id is made for the session.geo: alwaysnullhosts: one entry per host with new samples. The entry has the host'sname,urlandstatus, andhistory, a list of samples. A sample has the check's timetin Unix milliseconds, itslatency(nullfor a failed check) and itserror("timeout","unreachable"ornull).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; thePORTvariable changes it (see Settings) - stores the report files in
/data/reports, on the/datavolume. Before the backend starts,netwatch-server prepare-data-dircreates that directory and gives/dataand everything in it to usernetwatch. A host directory mounted at/datatherefore ends up owned by uid 1000. The command changes nothing outside/data, whatever links it meets there. - takes the client address from
X-Forwarded-Foronly on a request from a reverse proxy named inTRUSTED_PROXIES. nginx resolves the address and passes that one address to the backend. The backend is started withTRUSTED_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.confon 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/healthcheckthrough nginx every 30 seconds, as itsHEALTHCHECK. The check fails unless both nginx and the backend answer, and while report files cannot be written. Three failures in a row make the containerunhealthy; one pass makes ithealthyagain. - 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 three differences in the image:
PORTis nginx's public port, from 1 to 65535 and not8081, where the backend listens inside the container.DATA_DIRmust be/dataor a path below it, written in full, with no.or..part and no extra/. A path through a symbolic link that leads out of/datais refused too.TRUSTED_PROXIESnames the proxies nginx trusts, as IP addresses or CIDRs separated by commas. The backend's own setting is fixed to127.0.0.1/32.
- 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 to127.0.0.1.PORT, default8080: the port to listen on, from 1 to 65535. In the image this is nginx's port; the backend listens on8081.DATA_DIR, default./data/reports(/data/reportsin 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, default1073741824(1 GiB): the most bytes of report files kept inDATA_DIR, oldest deleted first. A positive whole number. See Report limits.REPORTS_PER_MINUTE, default60: how many reports each client address may send a minute. A positive whole number. See Report limits.TRUSTED_PROXIES, default10.0.0.0/8,172.16.0.0/12,192.168.0.0/16: the reverse proxies whoseX-Forwarded-ForandX-Real-IPheaders 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_USERNAMEandMETRICS_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,falseand0included. 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 written with a port, such as 198.51.100.7:5000 or
[2001:db8::1]:443, is read as that address. 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/reportstakes a report from the page. It needs no credentials; Report limits bound it instead.GET /.well-known/healthcheckanswers 200 with a JSON object:appname,version,now,uptime_seconds,uptime_humanandstatus, which is"ok". While report files cannot be written, it answers 503 with the same fields and"status":"error".GET /metricsserves Prometheus metrics, only whenMETRICS_USERNAMEandMETRICS_PASSWORDare 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 a file every minute,
whenever 10 MiB of reports are waiting, and when it stops.
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, and the next write, within a minute, tries them again. 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. Its file, if it
was 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 answers with the body {"status":"error"}.
- JSON only. A report must be sent with
Content-Type: application/json, with or without parameters such ascharset, 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 atext/plainPOST, 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, resolved through
TRUSTED_PROXIES, may sendREPORTS_PER_MINUTEreports a minute. Past that it gets 429 withRetry-After: 60. The minute slides: reports from the minute before still count, fading out over the current one. An address is sure never to be refused only while it sends at most half the limit in any 60 seconds. 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 to a report carries the headersX-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. - Size cap. The report files in
DATA_DIRmay total at mostDATA_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
/metricsitself - 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, resolved through TRUSTED_PROXIES, may make 60
requests to it a minute, whatever their credentials. Past that it gets 429 with
Retry-After: 60, and its credentials are not checked. The minute slides as it
does for reports, 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.htmlloadssrc/main.jsandsrc/styles.css.cmd/netwatch-server/andinternal/: the Go backend.cmd/holds onlymain, which calls intointernal/cli.configs/: nginx's configuration for the image,nginx.confandsecurity-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;Makefiletargets 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:
CONFIGholds the timing and display constants. The interval menu sets itsupdateInterval; the check timeout, the history span and the time axis are computed from it.WAN_HOSTSis the list of WAN targets, with the URL and method each is checked with.HostStateis one host's history, latest latency and status.AppStateholds the WAN hosts, the local hosts, the pause state and the round count, and computes the summary and the health.measureLatencyruns one check.tickruns 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.SparklineRendererdraws a graph on a canvas, scaled to the device's pixel ratio.buildUIbuilds the page;updateHostRow,updateSummaryandupdateHealthBoxredraw its parts.Reporterbuilds and posts the reports.buildReportis a pure function of the hosts' state.
The backend
netwatch-server is a Go HTTP server wired with fx. Its packages are in
internal/:
cliruns the command line: thecheck-cidrandprepare-data-dirsubcommands, whichbin/entrypoint.shruns before the server starts, and, given no arguments, the server. Any other arguments get a usage message and exit code 2.configreads the settings and refuses values the server cannot use.serverbuilds the router and the HTTP server and registers the routes.middlewareholds the request log, the security headers, CORS, the body limit, the rate limit, the client address resolution and the metrics.handlersanswers the health check and takes reports.reportbufis the in-memory buffer of reports and the files it writes them to. It also holdsPrepareDataDir, whichprepare-data-dirruns.healthcheck,loggerandglobalshold 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 thenodeonPATHwhen that node has the pinned major version and is new enough for the page's dependencies. Otherwise it installs the pinned node through nvm, fetched as a hash-verified archive. It uses theyarnonPATHonly when it is exactly the pinned version. Otherwise it installs that version through corepack, or through npm where there is no corepack. Then it runsyarn install --frozen-lockfile. It uses thegoonPATHonly when it is exactly the pinned version. Otherwise it installs that release from a hash-verified archive. Then it runsgo mod download. What it installs itself is linked into~/.local/bin, which has to be onPATH. It installs no Go linter and no Docker;make testandmake lintrun 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/apito anetwatch-serverrunning locally.script/build: build the page intodist/, then the staticnetwatch-serverbinary into the repo root. The binary's version isVERSIONfrom the environment. When that is unset or empty, it isgit describe --tags --always --dirty, as inscript/docker, ordevoutside a git checkout.script/run:script/build, then runnetwatch-serverwith its defaults.script/clean: remove whatscript/buildwrites.script/projectname: print the project name,netwatch, whichscript/dockeruses as the image tag.script/yarn: run yarn with the given arguments under the nodescript/bootstrapwould use.script/test: build thetestphase ofDockerfile, writing no image. The phase runs the backend's Go tests with the race detector and coverage, and the page's unit tests in itsfrontend-teststage.script/lint: build thelintphase ofDockerfile, writing no image. The phase runs golangci-lint over the Go packages, and eslint in itsfrontend-lintstage.script/fmt: format all files (writes): prettier over the JavaScript, CSS, HTML and Markdown, then gofmt overcmd/andinternal/. It runs on the host, with~/.local/binput onPATH.script/fmt-check: check the formatting (read-only), with the same scope asscript/fmt.script/check:script/test,script/lintandscript/fmt-check.script/add-dependency: add a page dependency, or move one to another version.make add-dependency PACKAGE=<name>@<version>runsyarn add --dev, which changespackage.jsonandyarn.locktogether, thenyarn install --frozen-lockfile.script/tidy: rungo mod tidy. To add a Go module, import it and runmake tidy. To move one to another version, edit itsrequireline ingo.mod, then runmake tidy.script/frontend-test: run the unit tests intest/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-fmtandscript/frontend-fmt-check: the prettier half ofscript/fmtandscript/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 ofscript/check, as it takes minutes.script/docker: build the image fromDockerfile, taggednetwatch.script/cibuild: the CI entrypoint. It runsscript/bootstrapandscript/check, then builds the image asscript/dockerdoes. The Gitea workflow.gitea/workflows/check.ymlruns it on every push.script/precommit: run by the git pre-commit hook; it runsscript/check.script/install-precommit: install the git pre-commit hook.make hookscalls 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, served by the same nginx image and
configuration as the Docker image. 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.