From 818accc454ccd42bc683143d066d9fa9d6d23e78 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 22 Feb 2026 15:59:10 +0100 Subject: [PATCH] Bring repo up to REPO_POLICIES.md standards - Add prettier (4-space indents) and reformat all files - Add Makefile with test/lint/fmt/fmt-check/check/docker targets - Add MIT LICENSE file - Add REPO_POLICIES.md - Fix Dockerfile: listen on 8080 with PORT env var via envsubst - Restructure README.md with all required sections - Set up pre-commit hook (make check) - Update .prettierignore, .gitignore, .dockerignore --- .dockerignore | 1 + .prettierignore | 4 + .prettierrc | 3 + Dockerfile | 11 +- LICENSE | 21 ++ Makefile | 18 ++ README.md | 132 ++++----- REPO_POLICIES.md | 110 ++++++++ index.html | 26 +- package.json | 35 +-- src/main.js | 686 ++++++++++++++++++++++++++--------------------- src/styles.css | 21 +- vite.config.js | 18 +- yarn.lock | 5 + 14 files changed, 671 insertions(+), 420 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 REPO_POLICIES.md diff --git a/.dockerignore b/.dockerignore index 01b8e10..e70c17c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ dist .git .DS_Store *.log +.claude diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c24da45 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +yarn.lock +.claude/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0a02bce --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} diff --git a/Dockerfile b/Dockerfile index 2083384..ba19b37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,10 @@ RUN yarn build FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab # Remove default config RUN rm /etc/nginx/conf.d/default.conf -# Custom nginx config: real_ip from RFC1918, access_log to stdout -COPY <<'EOF' /etc/nginx/conf.d/netwatch.conf +# Config template — envsubst replaces $PORT at container start +COPY <<'EOF' /etc/nginx/netwatch.conf.template server { - listen 80; + listen $PORT; server_name _; root /usr/share/nginx/html; @@ -44,4 +44,7 @@ EOF COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 +ENV PORT=8080 +EXPOSE 8080 + +CMD ["/bin/sh", "-c", "envsubst '$PORT' < /etc/nginx/netwatch.conf.template > /etc/nginx/conf.d/netwatch.conf && exec nginx -g 'daemon off;'"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b83a52 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 @sneak (https://sneak.berlin) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f4e348b --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: test lint fmt fmt-check check docker + +test: + timeout 30 yarn build + +lint: + yarn prettier --check . + +fmt: + yarn prettier --write . + +fmt-check: + yarn prettier --check . + +check: test lint fmt-check + +docker: + timeout 300 docker build -t netwatch . diff --git a/README.md b/README.md index 28d3006..60df9ea 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,6 @@ -# NetWatch +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. -Real-time network latency monitor SPA designed to be served from a static bucket or Docker container. - -## Features - -- **Real-time monitoring**: Updates every 2s with 300s history sparklines -- **9 WAN hosts**: Google Cloud, AWS, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, datavi.be -- **Local network group**: Local gateway (192.168.100.1) tracked separately -- **Health indicator**: Overall status box — green (HEALTHY) or red (DEGRADED) based on WAN reachability -- **Summary stats**: Reachable count, min/max/avg latency across WAN hosts only -- **Visual latency display**: Large color-coded figures with canvas sparkline graphs -- **Color coding**: - - Green: <50ms (excellent) - - Lime: <100ms (good) - - Yellow: <200ms (moderate) - - Orange: <500ms (poor) - - Red: >500ms (bad) - - Gray: offline/unreachable -- **Fixed chart axes**: Y-axis 0–1000ms, X-axis 0–300s -- **Play/pause**: Pause stops probes but history keeps scrolling (blank gaps, no false outage) -- **Clickable URLs**: Service URLs open in new tabs -- **>1000ms clamped**: Anything over 1s is treated as unreachable -- **Zero runtime dependencies**: All resources bundled into build artifacts -- **IPv4 only**: Designed for IPv4 connectivity testing - -## Technical Details - -- Latency measured via HEAD requests with cache-busting -- Uses `mode: 'no-cors'` to allow cross-origin requests where CORS headers aren't present -- 1-second timeout for unresponsive hosts (>1000ms clamped to unreachable) -- Canvas-based sparkline rendering with fixed axes -- Tailwind CSS v4 for styling -- Local gateway excluded from WAN summary statistics - -## Build +## Getting Started ```bash # Install dependencies @@ -47,34 +14,48 @@ yarn build # Preview production build yarn preview -``` -## Docker - -```bash +# Docker docker build -t netwatch . -docker run -p 8080:80 netwatch +docker run -p 8080:8080 netwatch ``` -The nginx config: -- Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12, 192.168/16) -- Access log goes to stdout -- Static assets cached with immutable headers +## Rationale -## Deployment +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. -After running `yarn build`, deploy the contents of the `dist/` directory to any static file host: +## Design -- AWS S3 -- Google Cloud Storage -- Cloudflare Pages -- Vercel -- Netlify -- GitHub Pages +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: -Or use the Docker image behind a reverse proxy. +- **`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) -## Output Structure +### Monitoring targets + +- **9 WAN hosts**: Google Cloud Console, AWS Console, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, datavi.be +- **1 Local host**: Local Gateway (192.168.100.1), 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/ @@ -84,15 +65,32 @@ dist/ └── index-*.js ``` -All assets are bundled and minified. No external dependencies at runtime. +## 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: + +- Listens on port 8080 by default (override with `PORT` env var) +- Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12, 192.168/16) +- Sends access logs to stdout +- Caches static assets with immutable headers ## Browser Compatibility -Requires modern browser with: -- ES modules support -- Fetch API -- Canvas API -- CSS custom properties +Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom properties. ## Limitations @@ -100,6 +98,18 @@ Requires modern browser with: - **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 +MIT. See [LICENSE](LICENSE). + +## Author + +[@sneak](https://sneak.berlin) diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md new file mode 100644 index 0000000..ad3c91d --- /dev/null +++ b/REPO_POLICIES.md @@ -0,0 +1,110 @@ +# Development Policies + +- Docker image references by tag are server-mutable, therefore using them is + an RCE vulnerability. All docker image references must use cryptographic + hashes to securely specify the exact image that is expected. + +- Correspondingly, `go install` commands using things like '@latest' are + also dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go + install targets using commit hashes which are cryptographically secure. + +- Every repo with software in it must have a Makefile in the root. Each + such Makefile should support `make test` (runs the project-specific + tests), `make lint`, `make fmt` (writes), `make fmt-check` (readonly), and + `make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make +docker` (builds docker image). + +- Every repo should have a Dockerfile. If the repo contains non-server + software, the Dockerfile should bring up a development environment and + `make check` (i.e. the docker build should fail if the branch is not + green). + +- Platform-specific standard formatting should be used. `black` for python, + `prettier` for js/css/etc, `go fmt` for go. The only changes to default + settings should be to specify four-space indents where applicable (i.e. + everything except `go fmt`). + +- If local testing is possible (it is not always), `make check` should be a + pre-commit hook. If it is not possible, `make lint && make fmt-check` + should be a pre-commit hook. + +- If a working `make test` takes more than 20 seconds, that's a bug that + needs fixing. In fact, there should be a timeout specified in the + `Makefile` that fails it automatically if it takes >30s. + +- Docker builds should time out in 5 minutes or less. + +- `main` must always pass `make check`, no exceptions. + +- Do all changes on a feature branch. You can do whatever you want on a + feature branch. + +- We have a standardized `.golangci.yml` which we reuse and is _NEVER_ to be + modified by an agent, only manually by the user. It can be copied from + `~/dev/upaas/.golangci.yml` if it exists at that location. + +- When specifying images or packages by hash in Dockerfiles or + `docker-compose.yml`, put a comment above the line and show the version + and date at which it was current. + +- For javascript, always use `yarn` over `npm`. + +- Whenever writing dates, ALWAYS write YYYY-MM-DD (ISO 8601). + +- Simple projects should be configured with environment variables, as is + standard for Dockerized applications. + +- Dockerized web services should listen on the default HTTP port of 8080 + unless overridden with the `PORT` environment variable. + +- The `README.md` is a project's primary documentation. It should contain + at a minimum the following sections: + - Description + - Include a short and complete description of the functionality and + purpose of the software as the first line in the readme. It must + include: + - the name + - the purpose + - the category (web server, SPA, command line tool, etc) + - the license + - the author + - eg: "µPaaS is an MIT-licensed Go web application by @sneak + that receives git-frontend webhooks and interacts with a + Docker server to build and deploy applications in realtime as + certain branches are updated." + - Getting Started + - a code block with copy-pasteable installation/use sections + - Rationale + - why does this exist? + - Design + - how is the program structured? + - TODO + - This is your TODO list for the project - update it meticulously, + even in between commits. Whenever planning, put your todo list in + the README so that a separate agent with new context can pick up + where you left off. + - License + - GPL or MIT or WTFPL - ask the user when beginning a new project + and include a LICENSE file in the root and in a section in the + README. + - Author + - @sneak (link `@sneak` to `https://sneak.berlin`). + +- When beginning a new project, initialize a git repo and make the first + commit simply the first version of the README.md in the root of the repo. + +- For Go packages, the module root is `sneak.berlin/go/...`, such + as `sneak.berlin/go/dnswatcher`. + +- We use SemVer always. + +- If no tag `1.0.0` or greater exists in the repository, modify the existing + migrations and assume no installed base or existing databases. If + `>=1.0.0`, database changes add new migration files. + +- New repos must have at a minimum the following files: + - `README.md`, `.git`, `.gitignore` + - `POLICIES.md` (copy from `~/Documents/_PROMPTS/POLICIES.md`) + - `Dockerfile`, `.dockerignore` + - for go: `go.mod`, `go.sum`, `.golangci.yml` + - for js: `package.json` diff --git a/index.html b/index.html index b698337..3af620d 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,17 @@ - + - - - - NetWatch - Network Latency Monitor - - - -
- - + + + + NetWatch - Network Latency Monitor + + + +
+ + diff --git a/package.json b/package.json index f2ad824..a5e3225 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,20 @@ { - "name": "netwatch", - "version": "1.0.0", - "description": "Real-time network latency monitor SPA", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "license": "MIT", - "devDependencies": { - "@tailwindcss/vite": "^4.1.18", - "autoprefixer": "^10.4.23", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18", - "vite": "^7.3.1" - } + "name": "netwatch", + "version": "1.0.0", + "description": "Real-time network latency monitor SPA", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "license": "MIT", + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "prettier": "^3.8.1", + "tailwindcss": "^4.1.18", + "vite": "^7.3.1" + } } diff --git a/src/main.js b/src/main.js index 4eeb18b..24800bc 100644 --- a/src/main.js +++ b/src/main.js @@ -1,261 +1,297 @@ -import './styles.css' +import "./styles.css"; // --- Configuration ----------------------------------------------------------- const CONFIG = Object.freeze({ - updateInterval: 2000, - historyDuration: 300, - requestTimeout: 1000, - maxLatency: 1000, - yAxisTicks: [0, 250, 500, 750, 1000], - xAxisTicks: [0, 60, 120, 180, 240, 300], - canvasHeight: 80, - get maxHistoryPoints() { - return Math.ceil((this.historyDuration * 1000) / this.updateInterval) - }, -}) + updateInterval: 2000, + historyDuration: 300, + requestTimeout: 1000, + maxLatency: 1000, + yAxisTicks: [0, 250, 500, 750, 1000], + xAxisTicks: [0, 60, 120, 180, 240, 300], + canvasHeight: 80, + get maxHistoryPoints() { + return Math.ceil((this.historyDuration * 1000) / this.updateInterval); + }, +}); const WAN_HOSTS = [ - { name: 'Google Cloud Console', url: 'https://console.cloud.google.com' }, - { name: 'AWS Console', url: 'https://console.aws.amazon.com' }, - { name: 'GitHub', url: 'https://github.com' }, - { name: 'Cloudflare', url: 'https://www.cloudflare.com' }, - { name: 'Microsoft Azure', url: 'https://portal.azure.com' }, - { name: 'DigitalOcean', url: 'https://www.digitalocean.com' }, - { name: 'Fastly CDN', url: 'https://www.fastly.com' }, - { name: 'Akamai', url: 'https://www.akamai.com' }, - { name: 'datavi.be', url: 'https://datavi.be' }, -] + { name: "Google Cloud Console", url: "https://console.cloud.google.com" }, + { name: "AWS Console", url: "https://console.aws.amazon.com" }, + { name: "GitHub", url: "https://github.com" }, + { name: "Cloudflare", url: "https://www.cloudflare.com" }, + { name: "Microsoft Azure", url: "https://portal.azure.com" }, + { name: "DigitalOcean", url: "https://www.digitalocean.com" }, + { name: "Fastly CDN", url: "https://www.fastly.com" }, + { name: "Akamai", url: "https://www.akamai.com" }, + { name: "datavi.be", url: "https://datavi.be" }, +]; -const LOCAL_HOSTS = [ - { name: 'Local Gateway', url: 'http://192.168.100.1' }, -] +const LOCAL_HOSTS = [{ name: "Local Gateway", url: "http://192.168.100.1" }]; // --- App State --------------------------------------------------------------- class HostState { - constructor(host) { - this.name = host.name - this.url = host.url - this.history = [] // { timestamp, latency, paused } - this.lastLatency = null - this.status = 'pending' // 'online' | 'offline' | 'error' | 'pending' - } + constructor(host) { + this.name = host.name; + this.url = host.url; + this.history = []; // { timestamp, latency, paused } + this.lastLatency = null; + this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending' + } - pushSample(timestamp, result) { - this.history.push({ timestamp, latency: result.latency, error: result.error }) - this._trim() - this.lastLatency = result.latency - if (result.error === 'timeout') this.status = 'error' - else if (result.error) this.status = 'offline' - else this.status = 'online' - } + pushSample(timestamp, result) { + this.history.push({ + timestamp, + latency: result.latency, + error: result.error, + }); + this._trim(); + this.lastLatency = result.latency; + if (result.error === "timeout") this.status = "error"; + else if (result.error) this.status = "offline"; + else this.status = "online"; + } - pushPaused(timestamp) { - this.history.push({ timestamp, latency: null, paused: true }) - this._trim() - } + pushPaused(timestamp) { + this.history.push({ timestamp, latency: null, paused: true }); + this._trim(); + } - averageLatency() { - const valid = this.history.filter(p => p.latency !== null) - if (valid.length === 0) return null - return Math.round(valid.reduce((s, p) => s + p.latency, 0) / valid.length) - } + averageLatency() { + const valid = this.history.filter((p) => p.latency !== null); + if (valid.length === 0) return null; + return Math.round( + valid.reduce((s, p) => s + p.latency, 0) / valid.length, + ); + } - _trim() { - while (this.history.length > CONFIG.maxHistoryPoints) this.history.shift() - } + _trim() { + while (this.history.length > CONFIG.maxHistoryPoints) + this.history.shift(); + } } class AppState { - constructor() { - this.wan = WAN_HOSTS.map(h => new HostState(h)) - this.local = LOCAL_HOSTS.map(h => new HostState(h)) - this.paused = false - } - - get allHosts() { return [...this.wan, ...this.local] } - - /** WAN-only stats from latest sample (excludes local) */ - wanStats() { - const reachable = this.wan.filter(h => h.lastLatency !== null) - const latencies = reachable.map(h => h.lastLatency) - const total = this.wan.length - if (latencies.length === 0) return { reachable: 0, total, min: null, max: null, avg: null } - return { - reachable: latencies.length, - total, - min: Math.min(...latencies), - max: Math.max(...latencies), - avg: Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length), + constructor() { + this.wan = WAN_HOSTS.map((h) => new HostState(h)); + this.local = LOCAL_HOSTS.map((h) => new HostState(h)); + this.paused = false; } - } - /** Overall health: true = healthy (more than half WAN reachable) */ - isHealthy() { - const reachable = this.wan.filter(h => h.lastLatency !== null).length - return reachable > this.wan.length / 2 - } + get allHosts() { + return [...this.wan, ...this.local]; + } + + /** WAN-only stats from latest sample (excludes local) */ + wanStats() { + const reachable = this.wan.filter((h) => h.lastLatency !== null); + const latencies = reachable.map((h) => h.lastLatency); + const total = this.wan.length; + if (latencies.length === 0) + return { reachable: 0, total, min: null, max: null, avg: null }; + return { + reachable: latencies.length, + total, + min: Math.min(...latencies), + max: Math.max(...latencies), + avg: Math.round( + latencies.reduce((a, b) => a + b, 0) / latencies.length, + ), + }; + } + + /** Overall health: true = healthy (more than half WAN reachable) */ + isHealthy() { + const reachable = this.wan.filter((h) => h.lastLatency !== null).length; + return reachable > this.wan.length / 2; + } } // --- Latency Measurement ----------------------------------------------------- async function measureLatency(url) { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), CONFIG.requestTimeout) + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + CONFIG.requestTimeout, + ); - const targetUrl = new URL(url) - targetUrl.searchParams.set('_cb', Date.now().toString()) + const targetUrl = new URL(url); + targetUrl.searchParams.set("_cb", Date.now().toString()); - const start = performance.now() + const start = performance.now(); - try { - await fetch(targetUrl.toString(), { - method: 'HEAD', - mode: 'no-cors', - cache: 'no-store', - signal: controller.signal, - }) - const latency = Math.round(performance.now() - start) - clearTimeout(timeoutId) - if (latency > CONFIG.maxLatency) return { latency: null, error: 'timeout' } - return { latency, error: null } - } catch (err) { - clearTimeout(timeoutId) - if (err.name === 'AbortError') return { latency: null, error: 'timeout' } - return { latency: null, error: 'unreachable' } - } + try { + await fetch(targetUrl.toString(), { + method: "HEAD", + mode: "no-cors", + cache: "no-store", + signal: controller.signal, + }); + const latency = Math.round(performance.now() - start); + clearTimeout(timeoutId); + if (latency > CONFIG.maxLatency) + return { latency: null, error: "timeout" }; + return { latency, error: null }; + } catch (err) { + clearTimeout(timeoutId); + if (err.name === "AbortError") + return { latency: null, error: "timeout" }; + return { latency: null, error: "unreachable" }; + } } // --- Color Helpers ----------------------------------------------------------- function latencyHex(latency) { - if (latency === null) return '#6b7280' - if (latency < 50) return '#22c55e' - if (latency < 100) return '#84cc16' - if (latency < 200) return '#eab308' - if (latency < 500) return '#f97316' - return '#ef4444' + if (latency === null) return "#6b7280"; + if (latency < 50) return "#22c55e"; + if (latency < 100) return "#84cc16"; + if (latency < 200) return "#eab308"; + if (latency < 500) return "#f97316"; + return "#ef4444"; } function latencyClass(latency, status) { - if (status === 'offline' || status === 'error' || latency === null) return 'text-gray-500' - if (latency < 50) return 'text-green-500' - if (latency < 100) return 'text-lime-500' - if (latency < 200) return 'text-yellow-500' - if (latency < 500) return 'text-orange-500' - return 'text-red-500' + if (status === "offline" || status === "error" || latency === null) + return "text-gray-500"; + if (latency < 50) return "text-green-500"; + if (latency < 100) return "text-lime-500"; + if (latency < 200) return "text-yellow-500"; + if (latency < 500) return "text-orange-500"; + return "text-red-500"; } // --- Sparkline Renderer ------------------------------------------------------ class SparklineRenderer { - static MARGIN = { left: 35, right: 10, top: 5, bottom: 18 } + static MARGIN = { left: 35, right: 10, top: 5, bottom: 18 }; - static draw(canvas, history) { - const ctx = canvas.getContext('2d') - const dpr = window.devicePixelRatio || 1 - const w = canvas.width / dpr - const h = canvas.height / dpr - const m = SparklineRenderer.MARGIN - const cw = w - m.left - m.right - const ch = h - m.top - m.bottom + static draw(canvas, history) { + const ctx = canvas.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + const w = canvas.width / dpr; + const h = canvas.height / dpr; + const m = SparklineRenderer.MARGIN; + const cw = w - m.left - m.right; + const ch = h - m.top - m.bottom; - ctx.clearRect(0, 0, w, h) - SparklineRenderer._drawYAxis(ctx, w, h, m, ch) - SparklineRenderer._drawXAxis(ctx, w, h, m, cw) + ctx.clearRect(0, 0, w, h); + SparklineRenderer._drawYAxis(ctx, w, h, m, ch); + SparklineRenderer._drawXAxis(ctx, w, h, m, cw); - const len = history.length - const pw = cw / (CONFIG.maxHistoryPoints - 1) - const getX = i => m.left + cw - (len - 1 - i) * pw - const getY = lat => m.top + ch - (lat / CONFIG.maxLatency) * ch + const len = history.length; + const pw = cw / (CONFIG.maxHistoryPoints - 1); + const getX = (i) => m.left + cw - (len - 1 - i) * pw; + const getY = (lat) => m.top + ch - (lat / CONFIG.maxLatency) * ch; - SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch) - SparklineRenderer._drawLine(ctx, history, getX, getY) - SparklineRenderer._drawTip(ctx, history, getX, getY) - } - - static _drawYAxis(ctx, w, h, m, ch) { - ctx.font = '9px monospace' - ctx.textAlign = 'right' - ctx.textBaseline = 'middle' - for (const tick of CONFIG.yAxisTicks) { - const y = m.top + ch - (tick / CONFIG.maxLatency) * ch - ctx.strokeStyle = 'rgba(255,255,255,0.1)' - ctx.lineWidth = 1 - ctx.beginPath(); ctx.moveTo(m.left, y); ctx.lineTo(w - m.right, y); ctx.stroke() - ctx.fillStyle = 'rgba(255,255,255,0.5)' - ctx.fillText(`${tick}`, m.left - 4, y) + SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch); + SparklineRenderer._drawLine(ctx, history, getX, getY); + SparklineRenderer._drawTip(ctx, history, getX, getY); } - } - static _drawXAxis(ctx, w, h, m, cw) { - ctx.textAlign = 'center' - ctx.textBaseline = 'top' - for (const tick of CONFIG.xAxisTicks) { - const x = m.left + cw - (tick / CONFIG.historyDuration) * cw - ctx.fillStyle = 'rgba(255,255,255,0.5)' - ctx.fillText(`-${tick}s`, x, h - m.bottom + 4) + static _drawYAxis(ctx, w, h, m, ch) { + ctx.font = "9px monospace"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const tick of CONFIG.yAxisTicks) { + const y = m.top + ch - (tick / CONFIG.maxLatency) * ch; + ctx.strokeStyle = "rgba(255,255,255,0.1)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(m.left, y); + ctx.lineTo(w - m.right, y); + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.5)"; + ctx.fillText(`${tick}`, m.left - 4, y); + } } - } - static _drawErrors(ctx, history, getX, top, ch) { - ctx.fillStyle = 'rgba(239, 68, 68, 0.2)' - let inErr = false, start = 0 - for (let i = 0; i < history.length; i++) { - const p = history[i] - const x = getX(i) - // Only real errors, not paused gaps - const isError = p.latency === null && !p.paused - if (isError && !inErr) { inErr = true; start = x } - else if (!isError && inErr) { inErr = false; ctx.fillRect(start, top, x - start, ch) } + static _drawXAxis(ctx, w, h, m, cw) { + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (const tick of CONFIG.xAxisTicks) { + const x = m.left + cw - (tick / CONFIG.historyDuration) * cw; + ctx.fillStyle = "rgba(255,255,255,0.5)"; + ctx.fillText(`-${tick}s`, x, h - m.bottom + 4); + } } - if (inErr) ctx.fillRect(start, top, getX(history.length - 1) - start + 5, ch) - } - static _drawLine(ctx, history, getX, getY) { - ctx.lineWidth = 2 - ctx.lineCap = 'round' - ctx.lineJoin = 'round' - let prev = null - for (let i = 0; i < history.length; i++) { - const p = history[i] - if (p.latency === null) { prev = null; continue } - const x = getX(i), y = getY(p.latency) - if (prev) { - ctx.strokeStyle = latencyHex(p.latency) - ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(x, y); ctx.stroke() - } - prev = { x, y } + static _drawErrors(ctx, history, getX, top, ch) { + ctx.fillStyle = "rgba(239, 68, 68, 0.2)"; + let inErr = false, + start = 0; + for (let i = 0; i < history.length; i++) { + const p = history[i]; + const x = getX(i); + // Only real errors, not paused gaps + const isError = p.latency === null && !p.paused; + if (isError && !inErr) { + inErr = true; + start = x; + } else if (!isError && inErr) { + inErr = false; + ctx.fillRect(start, top, x - start, ch); + } + } + if (inErr) + ctx.fillRect(start, top, getX(history.length - 1) - start + 5, ch); } - } - static _drawTip(ctx, history, getX, getY) { - for (let i = history.length - 1; i >= 0; i--) { - if (history[i].latency !== null) { - const x = getX(i), y = getY(history[i].latency) - ctx.fillStyle = latencyHex(history[i].latency) - ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill() - return - } + static _drawLine(ctx, history, getX, getY) { + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + let prev = null; + for (let i = 0; i < history.length; i++) { + const p = history[i]; + if (p.latency === null) { + prev = null; + continue; + } + const x = getX(i), + y = getY(p.latency); + if (prev) { + ctx.strokeStyle = latencyHex(p.latency); + ctx.beginPath(); + ctx.moveTo(prev.x, prev.y); + ctx.lineTo(x, y); + ctx.stroke(); + } + prev = { x, y }; + } } - } - static sizeCanvas(canvas) { - const dpr = window.devicePixelRatio || 1 - const rect = canvas.getBoundingClientRect() - canvas.width = rect.width * dpr - canvas.height = CONFIG.canvasHeight * dpr - const ctx = canvas.getContext('2d') - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.scale(dpr, dpr) - } + static _drawTip(ctx, history, getX, getY) { + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].latency !== null) { + const x = getX(i), + y = getY(history[i].latency); + ctx.fillStyle = latencyHex(history[i].latency); + ctx.beginPath(); + ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fill(); + return; + } + } + } + + static sizeCanvas(canvas) { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = CONFIG.canvasHeight * dpr; + const ctx = canvas.getContext("2d"); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + } } // --- UI Renderer ------------------------------------------------------------- function hostRowHTML(host, index) { - return ` + return `
@@ -275,12 +311,12 @@ function hostRowHTML(host, index) {
-
` + `; } function buildUI(state) { - const app = document.getElementById('app') - app.innerHTML = ` + const app = document.getElementById("app"); + app.innerHTML = `
@@ -317,12 +353,12 @@ function buildUI(state) {
- ${state.wan.map((h, i) => hostRowHTML(h, i)).join('')} + ${state.wan.map((h, i) => hostRowHTML(h, i)).join("")}

Local Network

- ${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i)).join('')} + ${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i)).join("")}
@@ -336,162 +372,192 @@ function buildUI(state) { offline

-
` + `; - requestAnimationFrame(() => { - document.querySelectorAll('.sparkline-canvas').forEach(c => SparklineRenderer.sizeCanvas(c)) - }) + requestAnimationFrame(() => { + document + .querySelectorAll(".sparkline-canvas") + .forEach((c) => SparklineRenderer.sizeCanvas(c)); + }); } // --- UI Updaters ------------------------------------------------------------- function updateHostRow(host, index) { - const latencyEl = document.querySelector(`.latency-value[data-host="${index}"]`) - const statusEl = document.querySelector(`.status-text[data-host="${index}"]`) - const canvas = document.querySelector(`.sparkline-canvas[data-host="${index}"]`) - if (!latencyEl || !statusEl || !canvas) return + const latencyEl = document.querySelector( + `.latency-value[data-host="${index}"]`, + ); + const statusEl = document.querySelector( + `.status-text[data-host="${index}"]`, + ); + const canvas = document.querySelector( + `.sparkline-canvas[data-host="${index}"]`, + ); + if (!latencyEl || !statusEl || !canvas) return; - if (host.lastLatency !== null) { - const cls = latencyClass(host.lastLatency, host.status) - latencyEl.innerHTML = `${host.lastLatency}ms` - } else if (host.status === 'offline' || host.status === 'error') { - latencyEl.innerHTML = `---` - } + if (host.lastLatency !== null) { + const cls = latencyClass(host.lastLatency, host.status); + latencyEl.innerHTML = `${host.lastLatency}ms`; + } else if (host.status === "offline" || host.status === "error") { + latencyEl.innerHTML = `---`; + } - const avg = host.averageLatency() - if (host.status === 'online' && avg !== null) { - statusEl.textContent = `avg: ${avg}ms` - statusEl.className = 'status-text text-xs text-gray-400 mt-1' - } else if (host.status === 'offline') { - statusEl.textContent = 'unreachable' - statusEl.className = 'status-text text-xs text-red-400 mt-1' - } else if (host.status === 'error') { - statusEl.textContent = 'timeout' - statusEl.className = 'status-text text-xs text-orange-400 mt-1' - } else { - statusEl.textContent = 'connecting...' - statusEl.className = 'status-text text-xs text-gray-500 mt-1' - } + const avg = host.averageLatency(); + if (host.status === "online" && avg !== null) { + statusEl.textContent = `avg: ${avg}ms`; + statusEl.className = "status-text text-xs text-gray-400 mt-1"; + } else if (host.status === "offline") { + statusEl.textContent = "unreachable"; + statusEl.className = "status-text text-xs text-red-400 mt-1"; + } else if (host.status === "error") { + statusEl.textContent = "timeout"; + statusEl.className = "status-text text-xs text-orange-400 mt-1"; + } else { + statusEl.textContent = "connecting..."; + statusEl.className = "status-text text-xs text-gray-500 mt-1"; + } - SparklineRenderer.draw(canvas, host.history) + SparklineRenderer.draw(canvas, host.history); } function updateSummary(state) { - const stats = state.wanStats() + const stats = state.wanStats(); - const reachableEl = document.getElementById('summary-reachable') - const minEl = document.getElementById('summary-min') - const maxEl = document.getElementById('summary-max') - const avgEl = document.getElementById('summary-avg') - if (!reachableEl) return + const reachableEl = document.getElementById("summary-reachable"); + const minEl = document.getElementById("summary-min"); + const maxEl = document.getElementById("summary-max"); + const avgEl = document.getElementById("summary-avg"); + if (!reachableEl) return; - reachableEl.textContent = `${stats.reachable}/${stats.total}` - reachableEl.className = stats.reachable === stats.total ? 'text-green-400' : - stats.reachable === 0 ? 'text-red-400' : 'text-yellow-400' + reachableEl.textContent = `${stats.reachable}/${stats.total}`; + reachableEl.className = + stats.reachable === stats.total + ? "text-green-400" + : stats.reachable === 0 + ? "text-red-400" + : "text-yellow-400"; - if (stats.min !== null) { - minEl.textContent = `${stats.min}ms`; minEl.className = latencyClass(stats.min, 'online') - maxEl.textContent = `${stats.max}ms`; maxEl.className = latencyClass(stats.max, 'online') - avgEl.textContent = `${stats.avg}ms`; avgEl.className = latencyClass(stats.avg, 'online') - } else { - for (const el of [minEl, maxEl, avgEl]) { el.textContent = '--ms'; el.className = 'text-gray-500' } - } + if (stats.min !== null) { + minEl.textContent = `${stats.min}ms`; + minEl.className = latencyClass(stats.min, "online"); + maxEl.textContent = `${stats.max}ms`; + maxEl.className = latencyClass(stats.max, "online"); + avgEl.textContent = `${stats.avg}ms`; + avgEl.className = latencyClass(stats.avg, "online"); + } else { + for (const el of [minEl, maxEl, avgEl]) { + el.textContent = "--ms"; + el.className = "text-gray-500"; + } + } } function updateHealthBox(state) { - const el = document.getElementById('health-text') - const box = document.getElementById('health-box') - if (!el || !box) return + const el = document.getElementById("health-text"); + const box = document.getElementById("health-box"); + if (!el || !box) return; - const anyData = state.wan.some(h => h.status !== 'pending') - if (!anyData) return + const anyData = state.wan.some((h) => h.status !== "pending"); + if (!anyData) return; - const healthy = state.isHealthy() - el.textContent = healthy ? 'HEALTHY' : 'DEGRADED' - el.className = healthy ? 'text-green-400 font-bold' : 'text-red-400 font-bold' - box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${ - healthy ? 'bg-green-900/20 border-green-700/50' : 'bg-red-900/20 border-red-700/50' - }` + const healthy = state.isHealthy(); + el.textContent = healthy ? "HEALTHY" : "DEGRADED"; + el.className = healthy + ? "text-green-400 font-bold" + : "text-red-400 font-bold"; + box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${ + healthy + ? "bg-green-900/20 border-green-700/50" + : "bg-red-900/20 border-red-700/50" + }`; } // --- Main Loop --------------------------------------------------------------- async function tick(state) { - const ts = Date.now() + const ts = Date.now(); - if (state.paused) { - // No probes — just push a paused marker so the chart keeps scrolling - for (const host of state.allHosts) { - host.pushPaused(ts) + if (state.paused) { + // No probes — just push a paused marker so the chart keeps scrolling + for (const host of state.allHosts) { + host.pushPaused(ts); + } + // Redraw sparklines only + state.allHosts.forEach((host, i) => { + const canvas = document.querySelector( + `.sparkline-canvas[data-host="${i}"]`, + ); + if (canvas) SparklineRenderer.draw(canvas, host.history); + }); + return; } - // Redraw sparklines only + + const results = await Promise.all( + state.allHosts.map((h) => measureLatency(h.url)), + ); + state.allHosts.forEach((host, i) => { - const canvas = document.querySelector(`.sparkline-canvas[data-host="${i}"]`) - if (canvas) SparklineRenderer.draw(canvas, host.history) - }) - return - } + host.pushSample(ts, results[i]); + updateHostRow(host, i); + }); - const results = await Promise.all( - state.allHosts.map(h => measureLatency(h.url)) - ) - - state.allHosts.forEach((host, i) => { - host.pushSample(ts, results[i]) - updateHostRow(host, i) - }) - - updateSummary(state) - updateHealthBox(state) + updateSummary(state); + updateHealthBox(state); } // --- Pause / Resume ---------------------------------------------------------- function togglePause(state) { - state.paused = !state.paused - const pauseIcon = document.getElementById('pause-icon') - const playIcon = document.getElementById('play-icon') - const pauseText = document.getElementById('pause-text') - const indicator = document.getElementById('status-indicator') + state.paused = !state.paused; + const pauseIcon = document.getElementById("pause-icon"); + const playIcon = document.getElementById("play-icon"); + const pauseText = document.getElementById("pause-text"); + const indicator = document.getElementById("status-indicator"); - if (state.paused) { - pauseIcon.classList.add('hidden'); playIcon.classList.remove('hidden') - pauseText.textContent = 'Resume' - indicator.textContent = 'Paused'; indicator.className = 'text-yellow-400' - } else { - pauseIcon.classList.remove('hidden'); playIcon.classList.add('hidden') - pauseText.textContent = 'Pause' - indicator.textContent = 'Running'; indicator.className = 'text-green-400' - } + if (state.paused) { + pauseIcon.classList.add("hidden"); + playIcon.classList.remove("hidden"); + pauseText.textContent = "Resume"; + indicator.textContent = "Paused"; + indicator.className = "text-yellow-400"; + } else { + pauseIcon.classList.remove("hidden"); + playIcon.classList.add("hidden"); + pauseText.textContent = "Pause"; + indicator.textContent = "Running"; + indicator.className = "text-green-400"; + } } // --- Resize ------------------------------------------------------------------ function handleResize(state) { - document.querySelectorAll('.sparkline-canvas').forEach((canvas, i) => { - SparklineRenderer.sizeCanvas(canvas) - const host = state.allHosts[i] - if (host) SparklineRenderer.draw(canvas, host.history) - }) + document.querySelectorAll(".sparkline-canvas").forEach((canvas, i) => { + SparklineRenderer.sizeCanvas(canvas); + const host = state.allHosts[i]; + if (host) SparklineRenderer.draw(canvas, host.history); + }); } // --- Bootstrap --------------------------------------------------------------- function init() { - const state = new AppState() - buildUI(state) + const state = new AppState(); + buildUI(state); - document.getElementById('pause-btn').addEventListener('click', () => togglePause(state)) + document + .getElementById("pause-btn") + .addEventListener("click", () => togglePause(state)); - tick(state) - setInterval(() => tick(state), CONFIG.updateInterval) + tick(state); + setInterval(() => tick(state), CONFIG.updateInterval); - window.addEventListener('resize', () => handleResize(state)) - setTimeout(() => handleResize(state), 100) + window.addEventListener("resize", () => handleResize(state)); + setTimeout(() => handleResize(state), 100); } -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init) +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); } else { - init() + init(); } diff --git a/src/styles.css b/src/styles.css index 54252fc..24e0fe3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,18 +1,23 @@ @import "tailwindcss"; @theme { - --color-latency-excellent: #22c55e; - --color-latency-good: #84cc16; - --color-latency-moderate: #eab308; - --color-latency-poor: #f97316; - --color-latency-bad: #ef4444; - --color-latency-offline: #6b7280; + --color-latency-excellent: #22c55e; + --color-latency-good: #84cc16; + --color-latency-moderate: #eab308; + --color-latency-poor: #f97316; + --color-latency-bad: #ef4444; + --color-latency-offline: #6b7280; } body { - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; } .sparkline-container { - background: linear-gradient(to bottom, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0) 100%); + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.02) 0%, + rgba(255, 255, 255, 0) 100% + ); } diff --git a/vite.config.js b/vite.config.js index ecf4b14..2cc1413 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,11 +1,11 @@ -import { defineConfig } from 'vite' -import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ - plugins: [tailwindcss()], - build: { - target: 'esnext', - minify: 'esbuild', - cssMinify: true, - }, -}) + plugins: [tailwindcss()], + build: { + target: "esnext", + minify: "esbuild", + cssMinify: true, + }, +}); diff --git a/yarn.lock b/yarn.lock index 2209c52..d342c47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -668,6 +668,11 @@ postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" +prettier@^3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== + rollup@^4.43.0: version "4.57.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"