check / check (push) Successful in 15s
The image's HEALTHCHECK requests /.well-known/healthcheck through nginx on the port from PORT, so it fails unless both processes answer. The backend reads PORT and DEBUG with strconv instead of viper, which turned a bad PORT into 0 and a bad DEBUG into false. Those, and a BIND_ADDRESS that is not an IP address, now stop the start with an error naming the variable; the TRUSTED_PROXIES error names it too. bin/entrypoint.sh also refuses a container PORT outside 1 to 65535, or 8081, where the backend listens, naming PORT. README.md gains "Running under upaas". Its first-run steps create the host directory owned by uid 1000, so the image changes no ownership. Model: opus-5-5
267 lines
11 KiB
Markdown
267 lines
11 KiB
Markdown
NetWatch is an MIT-licensed JavaScript single-page application by
|
||
[@sneak](https://sneak.berlin) that provides real-time network latency
|
||
monitoring to common internet hosts, displayed with color-coded figures and
|
||
sparkline graphs, served from a static bucket or Docker container.
|
||
|
||
## Getting Started
|
||
|
||
```bash
|
||
# Install dependencies
|
||
yarn install
|
||
|
||
# Development server
|
||
yarn dev
|
||
|
||
# Production build
|
||
yarn build
|
||
|
||
# Preview production build
|
||
yarn preview
|
||
|
||
# Docker
|
||
docker build -t netwatch .
|
||
docker run -p 8080:8080 netwatch
|
||
```
|
||
|
||
`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running
|
||
`netwatch-server` (see `backend/`) receives the reports the page posts.
|
||
|
||
## Entrypoints
|
||
|
||
This repository adheres to the
|
||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||
standard: normalized scripts in `script/` are the entrypoints for the
|
||
development workflow, and the Makefile targets are thin shims that call them.
|
||
The Go backend in `backend/` has its own `script/` directory and shim Makefile
|
||
(see [backend/README.md](backend/README.md)). The root scripts cover both
|
||
halves, so the root `make check` fails if either one is broken. We provide:
|
||
|
||
- `script/bootstrap` — install all dependencies (pinned node via nvm if needed,
|
||
yarn via corepack, `yarn install --frozen-lockfile`, the pinned Go unless one
|
||
at least as new as `backend/go.mod` asks for is installed, and the Go
|
||
modules), linking what it installs itself into `~/.local/bin`, which has to be
|
||
on `PATH`. It installs no Go linter and not Docker: `make lint` runs the
|
||
linter in Docker
|
||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||
git pre-commit hook
|
||
- `script/projectname` — print the project name (used for the Docker image tag)
|
||
- `script/test` — run `script/frontend-test`, then the backend's Go tests, both
|
||
within one 30-second timeout
|
||
- `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by
|
||
building the lint stage of `Dockerfile` without the cache
|
||
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
||
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
|
||
- `script/check` — run test, lint, and fmt-check
|
||
- `script/frontend-test` — run the production build as the frontend's test (no
|
||
unit tests yet)
|
||
- `script/frontend-lint` — run prettier in check mode
|
||
- `script/frontend-fmt` — format everything prettier understands (writes)
|
||
- `script/frontend-fmt-check` — check prettier formatting (read-only)
|
||
- `script/frontend-check` — the frontend half of `script/check`, for
|
||
`Dockerfile`, whose node build stage has neither Go nor Docker
|
||
- `script/frontend-viewport-test` — responsive-layout verification of the built
|
||
frontend in a containerised headless Chrome (see
|
||
[test/viewport/README.md](test/viewport/README.md)). Not part of
|
||
`script/check`: it needs Docker and takes minutes.
|
||
- `script/docker` — build the image from `Dockerfile` without the build cache,
|
||
tagged `netwatch` via `script/projectname`
|
||
- `script/cibuild` — CI entrypoint: builds the image
|
||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||
- `script/install-precommit` — install the git pre-commit hook
|
||
|
||
## Responsive layout
|
||
|
||
The narrow-viewport layout lives in the `max-width: 768px` media block in
|
||
`src/styles.css`. It is verified automatically by `make frontend-viewport-test`,
|
||
which drives a digest-pinned headless Chrome against the built `dist/` and
|
||
asserts on computed layout at widths derived from that CSS — one pixel either
|
||
side of every breakpoint it declares, plus a 320px floor, a desktop baseline and
|
||
two landscape sizes. See [test/viewport/README.md](test/viewport/README.md) for
|
||
what it covers and what it genuinely cannot.
|
||
|
||
## Rationale
|
||
|
||
When debugging network issues, it's useful to have a persistent at-a-glance view
|
||
of latency and reachability to multiple well-known internet endpoints. NetWatch
|
||
provides this as a zero-dependency SPA that can be deployed anywhere static
|
||
files are served, with no backend required.
|
||
|
||
## Design
|
||
|
||
The application is a single-page app built with Vite and Tailwind CSS v4. All
|
||
code lives in `src/main.js` with a class-based architecture:
|
||
|
||
- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis
|
||
ticks, etc.)
|
||
- **`HostState`**: Per-host state management — history buffer, latency tracking,
|
||
status transitions
|
||
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
|
||
state, aggregate stats
|
||
- **`SparklineRenderer`**: Canvas 2D sparkline drawing with fixed axes,
|
||
color-coded line segments, error regions, and DPR-aware scaling
|
||
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` /
|
||
`updateSummary()` / `updateHealthBox()` handle incremental updates
|
||
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
|
||
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
|
||
false outage)
|
||
- **`Reporter`**: Posts collected samples to the backend
|
||
|
||
### Reporting
|
||
|
||
Every `reportInterval` (default 60s) the page POSTs a JSON report to the
|
||
same-origin path `/api/v1/reports`: a random per-browser `clientId` kept in
|
||
`localStorage`, `geo` sent as null, and each host's unreported, non-paused
|
||
samples (timestamp, latency, error). A per-host high-water mark makes every
|
||
report a delta, so only new samples are sent; the mark advances only on a
|
||
delivered report, and while paused nothing is sent. Delivery failure is quiet —
|
||
one debug-log line per outage, retried at the next interval, never blocking
|
||
probing. The report-building step is a pure function of host state.
|
||
|
||
### Monitoring targets
|
||
|
||
- **22 WAN hosts**: datavi.be, Anthropic API, OpenAI API, AWS Console, GCP
|
||
Console, Azure, Cloudflare, Fastly, Akamai, GitHub, B2, 7 S3 regional
|
||
endpoints (Cape Town, London, Bahrain, Tokyo, Sydney, Oregon, São Paulo), 4
|
||
GCS locational endpoints (Iowa, Belgium, Singapore, Sydney)
|
||
- **Local CPE**: Cable modem at 192.168.100.1 (always monitored)
|
||
- **Local Gateway**: Auto-detected on startup by probing common default gateway
|
||
addresses (192.168.1.1, 192.168.0.1, 192.168.8.1, 10.0.0.1); first responder
|
||
wins. Note: modern browsers enforce Private Network Access restrictions that
|
||
block public-origin pages from reaching RFC1918 addresses, so local targets
|
||
only work when NetWatch is served from localhost or a private address.
|
||
|
||
Local hosts are tracked separately from WAN stats.
|
||
|
||
### Latency measurement
|
||
|
||
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with
|
||
`performance.now()`. 1-second timeout; anything over 1000ms is clamped to
|
||
unreachable. IPv4 only.
|
||
|
||
### Color coding
|
||
|
||
| Latency | Color |
|
||
| ----------- | ------ |
|
||
| < 50ms | Green |
|
||
| < 100ms | Lime |
|
||
| < 200ms | Yellow |
|
||
| < 500ms | Orange |
|
||
| >= 500ms | Red |
|
||
| Unreachable | Gray |
|
||
|
||
### Output structure
|
||
|
||
```
|
||
dist/
|
||
├── index.html
|
||
└── assets/
|
||
├── index-*.css
|
||
└── index-*.js
|
||
```
|
||
|
||
## Features
|
||
|
||
- Real-time monitoring with 2s update interval and 300s history sparklines
|
||
- Health indicator: green (HEALTHY) or red (DEGRADED) based on WAN reachability
|
||
- Summary stats: reachable count, min/max/avg latency across WAN hosts only
|
||
- Fixed chart axes: Y-axis 0–1000ms, X-axis 0–300s
|
||
- Color-coded latency figures and sparkline line segments
|
||
- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no
|
||
false outage)
|
||
- Clickable service URLs
|
||
- Canvas-based sparkline rendering with devicePixelRatio scaling
|
||
- Zero runtime dependencies: all resources bundled into build artifacts
|
||
|
||
## Deployment
|
||
|
||
After running `yarn build`, deploy the contents of the `dist/` directory to any
|
||
static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or
|
||
use the Docker image behind a reverse proxy.
|
||
|
||
The Docker image, built from `Dockerfile`, is the whole service in one
|
||
container: nginx serves the built frontend and passes `/api/` and
|
||
`/.well-known/healthcheck` to the Go backend, `netwatch-server`, which listens
|
||
only inside the container, on `127.0.0.1:8081`. The image:
|
||
|
||
- Listens on port 8080 by default (override with `PORT` env var)
|
||
- Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12,
|
||
192.168/16)
|
||
- Sends access logs to stdout
|
||
- Caches static assets with immutable headers
|
||
- Stores reports in `DATA_DIR`, `/data/reports` by default, on the `/data`
|
||
volume. The backend runs as user `netwatch` (uid 1000), so a directory
|
||
bind-mounted at `/data` must be writable by uid 1000
|
||
- Writes buffered reports to disk on `docker stop`, and exits non-zero if nginx
|
||
or the backend exits on its own, so the platform restarts it
|
||
|
||
## Running under upaas
|
||
|
||
What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
|
||
|
||
- **Port:** container port `8080`.
|
||
- **Volume:** container path `/data`; the reports are kept in `/data/reports`.
|
||
- **First run:** upaas bind-mounts the host directory it is given and does not
|
||
create it, and the backend, which runs as uid 1000, does not start unless it
|
||
can write there. Create the directory, owned by uid 1000, before the first
|
||
deploy:
|
||
|
||
```bash
|
||
mkdir -p /path/to/data
|
||
chown 1000:1000 /path/to/data
|
||
```
|
||
|
||
- **Environment variables:** none is required. An empty one counts as unset, and
|
||
one set to a value netwatch cannot use stops the container at start, with the
|
||
reason in its log.
|
||
- `PORT`, default `8080`: the container port, from 1 to 65535. `8081` cannot
|
||
be used: the backend listens on it inside the container
|
||
- `REPORTS_PER_MINUTE`, default `60`: reports each client address may send a
|
||
minute
|
||
- `DATA_DIR_MAX_BYTES`, default `1073741824` (1 GiB): the most room the
|
||
report files may take
|
||
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
|
||
the API
|
||
- `DEBUG`, default `false`: debug logging
|
||
- `DATA_DIR`, default `/data/reports`: leave unset; reports kept outside
|
||
`/data` do not survive a redeploy
|
||
- `TRUSTED_PROXIES`, default loopback and RFC1918: leave unset. The
|
||
backend's only client is nginx, on loopback, which passes on the client
|
||
address; nginx takes it from `X-Forwarded-For` only from RFC1918
|
||
addresses.
|
||
- **Health check:** the image's `HEALTHCHECK` requests
|
||
`/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless
|
||
both nginx and the backend answer. upaas reads the container's health 60
|
||
seconds after a deploy and fails the deploy unless it is `healthy`. The
|
||
container also stops when either process exits.
|
||
|
||
## Browser Compatibility
|
||
|
||
Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom
|
||
properties.
|
||
|
||
## Limitations
|
||
|
||
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
|
||
`no-cors` mode which allows the request but provides opaque responses. Latency
|
||
is still measurable based on request timing.
|
||
- **Local gateway**: The 192.168.100.1 endpoint requires the host to be
|
||
accessible from your network.
|
||
- **Network conditions**: Measurements reflect browser-to-endpoint latency,
|
||
which includes your local network, ISP, and internet routing.
|
||
|
||
## TODO
|
||
|
||
- Add unit tests
|
||
- Add eslint for JS linting (currently lint target runs prettier only)
|
||
- Add configurable host list (environment variable or config file)
|
||
- Add latency history export (CSV/JSON)
|
||
- Add notification/alert when status changes to DEGRADED
|
||
|
||
## License
|
||
|
||
MIT. See [LICENSE](LICENSE).
|
||
|
||
## Author
|
||
|
||
[@sneak](https://sneak.berlin)
|