check / check (push) Successful in 2m6s
Closes #75. `bin/entrypoint.sh`, which already runs as root, now makes the data directory usable before the backend starts: it creates `DATA_DIR` if missing, gives it and `/data` to the `netwatch` user (`chown -R`), and sets mode 750 on both, the mode the backend gives a directory it creates. The backend still runs as `netwatch`. The README "Running under upaas" section loses its first-run step that created and chowned the host directory and names only the path to mount. The Dockerfile's build-time `mkdir` and `chown` of `/data` are gone, since the entrypoint now does this on every start. What the diff does not show: - The host directory mounted at `/data` ends up owned by uid 1000 with mode 750, and everything under `DATA_DIR` is chowned to uid 1000 on every start. - If the directory cannot be created or chowned, the container stops with that tool's error before either process starts. Recorded runs with `--mount type=bind`: an empty directory owned by root (mode 755, and again mode 700), and one holding a `reports` directory and report file owned by uid 1001 with mode 700. Each time the container turned healthy, `netwatch-server` ran as `netwatch`, and a posted report was written to `DATA_DIR`; a second start on the root-owned and the uid 1001 directories did the same. Judgement call: `/data` itself is given to `netwatch` as well as `DATA_DIR`, so the backend can reach `DATA_DIR` inside a host directory with mode 700. Model: opus-5-5 Reviewed-on: #76 Co-authored-by: clawbot <35+clawbot@noreply.example.org>
267 lines
12 KiB
Markdown
267 lines
12 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 (the pinned node via nvm unless
|
||
one new enough for the frontend's dependencies is installed, 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: runs `script/bootstrap` and `script/check`,
|
||
then builds the image as `script/docker` does, without the build cache
|
||
- `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)
|
||
- Takes the client address from `X-Forwarded-For` only on requests from the
|
||
reverse proxies named in `TRUSTED_PROXIES`, and by default from none
|
||
- Sends access logs to stdout
|
||
- Caches static assets with immutable headers
|
||
- Sends the security headers `REPO_POLICIES.md` requires on every response, as
|
||
`security-headers.conf` sets them, in place of the backend's own
|
||
- Stores reports in `DATA_DIR`, `/data/reports` by default, on the `/data`
|
||
volume. Before the backend starts, the image creates `DATA_DIR` and gives it
|
||
and `/data` to user `netwatch` (uid 1000), which the backend runs as, so a
|
||
host directory bind-mounted at `/data` ends up owned 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`.
|
||
- **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 empty: set it to the address the reverse proxy
|
||
in front of the container connects from, as an IP address or CIDR; several
|
||
are separated by commas. nginx takes the client address from
|
||
`X-Forwarded-For` only on a request from one of them, and the rate limit
|
||
counts that address. Unset, `X-Forwarded-For` is ignored and every client
|
||
behind the proxy shares the proxy's one allowance of `REPORTS_PER_MINUTE`.
|
||
Name only addresses nothing but the proxy connects from: any client that
|
||
connects from one can write its own `X-Forwarded-For`, and through a port
|
||
Docker publishes, every client may connect from the Docker network's
|
||
gateway, such as `172.17.0.1`.
|
||
- **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)
|