Each target check now times out after 80% of the refresh interval, 24 seconds at 30 seconds, where it was capped at 3 seconds, so slow, far targets are recorded with their real time. A round asked for while the last one's checks are still waiting, after an interval change or by the recovery probe, is skipped, so rounds never overlap; the recovery probe starts no new checks while its last ones wait. The frontend has its first unit tests, run by script/frontend-test with Node's built-in test runner. index.html now links src/styles.css, which src/main.js imported, since Node cannot import CSS. Model: opus-5-5
12 KiB
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 asbackend/go.modasks for is installed, and the Go modules), linking what it installs itself into~/.local/bin, which has to be onPATH. It installs no Go linter and not Docker:make lintruns the linter in Dockerscript/setup— make a fresh clone ready for development: bootstrap plus the git pre-commit hookscript/projectname— print the project name (used for the Docker image tag)script/test— runscript/frontend-test, then the backend's Go tests, both within one 30-second timeoutscript/lint— runscript/frontend-lint, then golangci-lint in Docker, by building the lint stage ofDockerfilewithout the cachescript/fmt— format all files (writes): prettier, then gofmt overbackend/script/fmt-check— check formatting (read-only): prettier, then gofmtscript/check— run test, lint, and fmt-checkscript/frontend-test— run the unit tests intest/unit/with Node's built-in test runner, then the production buildscript/frontend-lint— run prettier in check modescript/frontend-fmt— format everything prettier understands (writes)script/frontend-fmt-check— check prettier formatting (read-only)script/frontend-check— the frontend half ofscript/check, forDockerfile, whose node build stage has neither Go nor Dockerscript/frontend-viewport-test— responsive-layout verification of the built frontend in a containerised headless Chrome (see test/viewport/README.md). Not part ofscript/check: it needs Docker and takes minutes.script/docker— build the image fromDockerfilewithout the build cache, taggednetwatchviascript/projectnamescript/cibuild— CI entrypoint: runsscript/bootstrapandscript/check, then builds the image asscript/dockerdoes, without the build cachescript/precommit— run by the git pre-commit hook; runsscript/checkscript/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 transitionsAppState: Top-level state container — WAN hosts, local hosts, pause state, aggregate statsSparklineRenderer: 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 viaPromise.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(). Each check times out after 80% of the refresh interval (24
seconds at 30 seconds) and is then recorded as a timeout, so a round's checks
have all finished before the next round is due. A round due while the last one
is still waiting, which happens only after an interval change or when the
recovery probe starts one, is skipped. 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
PORTenv var) - Takes the client address from
X-Forwarded-Foronly on requests from the reverse proxies named inTRUSTED_PROXIES, and by default from none - Sends access logs to stdout
- Caches static assets with immutable headers
- Sends the security headers
REPO_POLICIES.mdrequires on every response, assecurity-headers.confsets them, in place of the backend's own - Stores reports in
DATA_DIR,/data/reportsby default, on the/datavolume. Before the backend starts, the image createsDATA_DIRand gives it and/datato usernetwatch(uid 1000), which the backend runs as, so a host directory bind-mounted at/dataends 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, default8080: the container port, from 1 to 65535.8081cannot be used: the backend listens on it inside the containerREPORTS_PER_MINUTE, default60: reports each client address may send a minuteDATA_DIR_MAX_BYTES, default1073741824(1 GiB): the most room the report files may takeCORS_ALLOWED_ORIGINS, default empty: other origins whose pages may call the APIDEBUG, defaultfalse: debug loggingDATA_DIR, default/data/reports: leave unset; reports kept outside/datado not survive a redeployTRUSTED_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 fromX-Forwarded-Foronly on a request from one of them, and the rate limit counts that address. Unset,X-Forwarded-Foris ignored and every client behind the proxy shares the proxy's one allowance ofREPORTS_PER_MINUTE. Name only addresses nothing but the proxy connects from: any client that connects from one can write its ownX-Forwarded-For, and through a port Docker publishes, every client may connect from the Docker network's gateway, such as172.17.0.1.
- Health check: the image's
HEALTHCHECKrequests/.well-known/healthcheckthrough 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 ishealthy. 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-corsmode 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.