Files
netwatch/README.md
T
clawbot 02dbd1a89b
check / check (push) Successful in 50s
nginx: trust X-Forwarded-For only from TRUSTED_PROXIES (closes #64)
nginx trusted X-Forwarded-For from every RFC1918 address, so a client
reaching it from one could write a new address on each request and
get a fresh rate-limit allowance. The container's TRUSTED_PROXIES now
names the reverse proxies nginx trusts, none by default.
bin/entrypoint.sh makes each entry a CIDR, checks it with the new
"netwatch-server check-cidr", which runs the server's own
TRUSTED_PROXIES parsing, and writes one set_real_ip_from line per
entry into /etc/nginx/trusted-proxies.conf, which nginx.conf includes.
The backend is started with TRUSTED_PROXIES=127.0.0.1/32, since nginx
is its only client. The viewport test mounts an empty file there.

Model: opus-5-5
2026-09-29 05:48:43 +00:00

12 KiB
Raw Blame History

NetWatch is an MIT-licensed JavaScript single-page application by @sneak 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

# 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 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). 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). 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 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
  • 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 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:

    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 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.

Author

@sneak