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`: the directory the reports are kept in: `/data` or a path below it, with no `.` or `..` part and no extra `/`. The container also stops if a part of the path that exists, `/data` included, is a symbolic link - `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)