Files
netwatch/README.md
clawbot 64587679a4
check / check (push) Failing after 12m54s
Entrypoint checks DATA_DIR in full before acting on it as root (closes #80)
`bin/entrypoint.sh` now checks `DATA_DIR` before it creates anything or
changes an owner or mode: it must be `/data` or a path below it with no
`.`, `..` or empty part, and no part of it that exists, `/data`
included, may be a symbolic link. Anything else stops the start with one
message naming `DATA_DIR`. Only then does it create `DATA_DIR`, give
`/data` and everything in it to `netwatch` (`chown -R -h`, so a link in
it is not followed) and set mode 750 on `/data` and `DATA_DIR`. The
README section "Running under upaas" says which values are accepted.

Model: opus-5-5
2026-09-29 10:56:53 +00:00

12 KiB
Raw Permalink 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 (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). 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 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 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.

Author

@sneak