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
This commit is contained in:
Jeffrey Paul 2026-02-22 15:59:10 +01:00
parent ca403e68d1
commit 818accc454
14 changed files with 671 additions and 420 deletions

View File

@ -3,3 +3,4 @@ dist
.git .git
.DS_Store .DS_Store
*.log *.log
.claude

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
dist/
node_modules/
yarn.lock
.claude/

3
.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"tabWidth": 4
}

View File

@ -10,10 +10,10 @@ RUN yarn build
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
# Remove default config # Remove default config
RUN rm /etc/nginx/conf.d/default.conf RUN rm /etc/nginx/conf.d/default.conf
# Custom nginx config: real_ip from RFC1918, access_log to stdout # Config template — envsubst replaces $PORT at container start
COPY <<'EOF' /etc/nginx/conf.d/netwatch.conf COPY <<'EOF' /etc/nginx/netwatch.conf.template
server { server {
listen 80; listen $PORT;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
@ -44,4 +44,7 @@ EOF
COPY --from=build /app/dist /usr/share/nginx/html 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;'"]

21
LICENSE Normal file
View File

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

18
Makefile Normal file
View File

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

132
README.md
View File

@ -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. ## Getting Started
## 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 01000ms, X-axis 0300s
- **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
```bash ```bash
# Install dependencies # Install dependencies
@ -47,34 +14,48 @@ yarn build
# Preview production build # Preview production build
yarn preview yarn preview
```
## Docker # Docker
```bash
docker build -t netwatch . docker build -t netwatch .
docker run -p 8080:80 netwatch docker run -p 8080:8080 netwatch
``` ```
The nginx config: ## Rationale
- 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
## 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 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:
- Google Cloud Storage
- Cloudflare Pages
- Vercel
- Netlify
- GitHub Pages
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/ dist/
@ -84,15 +65,32 @@ dist/
└── index-*.js └── 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 01000ms, X-axis 0300s
- 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 ## Browser Compatibility
Requires modern browser with: Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom properties.
- ES modules support
- Fetch API
- Canvas API
- CSS custom properties
## Limitations ## 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. - **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. - **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 ## License
MIT MIT. See [LICENSE](LICENSE).
## Author
[@sneak](https://sneak.berlin)

110
REPO_POLICIES.md Normal file
View File

@ -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`

View File

@ -1,10 +1,14 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetWatch - Network Latency Monitor</title> <title>NetWatch - Network Latency Monitor</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>" /> <link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>"
/>
</head> </head>
<body class="bg-gray-900 text-white min-h-screen"> <body class="bg-gray-900 text-white min-h-screen">
<div id="app"></div> <div id="app"></div>

View File

@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vite": "^7.3.1" "vite": "^7.3.1"
} }

View File

@ -1,4 +1,4 @@
import './styles.css' import "./styles.css";
// --- Configuration ----------------------------------------------------------- // --- Configuration -----------------------------------------------------------
@ -11,244 +11,280 @@ const CONFIG = Object.freeze({
xAxisTicks: [0, 60, 120, 180, 240, 300], xAxisTicks: [0, 60, 120, 180, 240, 300],
canvasHeight: 80, canvasHeight: 80,
get maxHistoryPoints() { get maxHistoryPoints() {
return Math.ceil((this.historyDuration * 1000) / this.updateInterval) return Math.ceil((this.historyDuration * 1000) / this.updateInterval);
}, },
}) });
const WAN_HOSTS = [ const WAN_HOSTS = [
{ name: 'Google Cloud Console', url: 'https://console.cloud.google.com' }, { name: "Google Cloud Console", url: "https://console.cloud.google.com" },
{ name: 'AWS Console', url: 'https://console.aws.amazon.com' }, { name: "AWS Console", url: "https://console.aws.amazon.com" },
{ name: 'GitHub', url: 'https://github.com' }, { name: "GitHub", url: "https://github.com" },
{ name: 'Cloudflare', url: 'https://www.cloudflare.com' }, { name: "Cloudflare", url: "https://www.cloudflare.com" },
{ name: 'Microsoft Azure', url: 'https://portal.azure.com' }, { name: "Microsoft Azure", url: "https://portal.azure.com" },
{ name: 'DigitalOcean', url: 'https://www.digitalocean.com' }, { name: "DigitalOcean", url: "https://www.digitalocean.com" },
{ name: 'Fastly CDN', url: 'https://www.fastly.com' }, { name: "Fastly CDN", url: "https://www.fastly.com" },
{ name: 'Akamai', url: 'https://www.akamai.com' }, { name: "Akamai", url: "https://www.akamai.com" },
{ name: 'datavi.be', url: 'https://datavi.be' }, { name: "datavi.be", url: "https://datavi.be" },
] ];
const LOCAL_HOSTS = [ const LOCAL_HOSTS = [{ name: "Local Gateway", url: "http://192.168.100.1" }];
{ name: 'Local Gateway', url: 'http://192.168.100.1' },
]
// --- App State --------------------------------------------------------------- // --- App State ---------------------------------------------------------------
class HostState { class HostState {
constructor(host) { constructor(host) {
this.name = host.name this.name = host.name;
this.url = host.url this.url = host.url;
this.history = [] // { timestamp, latency, paused } this.history = []; // { timestamp, latency, paused }
this.lastLatency = null this.lastLatency = null;
this.status = 'pending' // 'online' | 'offline' | 'error' | 'pending' this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
} }
pushSample(timestamp, result) { pushSample(timestamp, result) {
this.history.push({ timestamp, latency: result.latency, error: result.error }) this.history.push({
this._trim() timestamp,
this.lastLatency = result.latency latency: result.latency,
if (result.error === 'timeout') this.status = 'error' error: result.error,
else if (result.error) this.status = 'offline' });
else this.status = 'online' 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) { pushPaused(timestamp) {
this.history.push({ timestamp, latency: null, paused: true }) this.history.push({ timestamp, latency: null, paused: true });
this._trim() this._trim();
} }
averageLatency() { averageLatency() {
const valid = this.history.filter(p => p.latency !== null) const valid = this.history.filter((p) => p.latency !== null);
if (valid.length === 0) return null if (valid.length === 0) return null;
return Math.round(valid.reduce((s, p) => s + p.latency, 0) / valid.length) return Math.round(
valid.reduce((s, p) => s + p.latency, 0) / valid.length,
);
} }
_trim() { _trim() {
while (this.history.length > CONFIG.maxHistoryPoints) this.history.shift() while (this.history.length > CONFIG.maxHistoryPoints)
this.history.shift();
} }
} }
class AppState { class AppState {
constructor() { constructor() {
this.wan = WAN_HOSTS.map(h => new HostState(h)) this.wan = WAN_HOSTS.map((h) => new HostState(h));
this.local = LOCAL_HOSTS.map(h => new HostState(h)) this.local = LOCAL_HOSTS.map((h) => new HostState(h));
this.paused = false this.paused = false;
} }
get allHosts() { return [...this.wan, ...this.local] } get allHosts() {
return [...this.wan, ...this.local];
}
/** WAN-only stats from latest sample (excludes local) */ /** WAN-only stats from latest sample (excludes local) */
wanStats() { wanStats() {
const reachable = this.wan.filter(h => h.lastLatency !== null) const reachable = this.wan.filter((h) => h.lastLatency !== null);
const latencies = reachable.map(h => h.lastLatency) const latencies = reachable.map((h) => h.lastLatency);
const total = this.wan.length const total = this.wan.length;
if (latencies.length === 0) return { reachable: 0, total, min: null, max: null, avg: null } if (latencies.length === 0)
return { reachable: 0, total, min: null, max: null, avg: null };
return { return {
reachable: latencies.length, reachable: latencies.length,
total, total,
min: Math.min(...latencies), min: Math.min(...latencies),
max: Math.max(...latencies), max: Math.max(...latencies),
avg: Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length), avg: Math.round(
} latencies.reduce((a, b) => a + b, 0) / latencies.length,
),
};
} }
/** Overall health: true = healthy (more than half WAN reachable) */ /** Overall health: true = healthy (more than half WAN reachable) */
isHealthy() { isHealthy() {
const reachable = this.wan.filter(h => h.lastLatency !== null).length const reachable = this.wan.filter((h) => h.lastLatency !== null).length;
return reachable > this.wan.length / 2 return reachable > this.wan.length / 2;
} }
} }
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
async function measureLatency(url) { async function measureLatency(url) {
const controller = new AbortController() const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.requestTimeout) const timeoutId = setTimeout(
() => controller.abort(),
CONFIG.requestTimeout,
);
const targetUrl = new URL(url) const targetUrl = new URL(url);
targetUrl.searchParams.set('_cb', Date.now().toString()) targetUrl.searchParams.set("_cb", Date.now().toString());
const start = performance.now() const start = performance.now();
try { try {
await fetch(targetUrl.toString(), { await fetch(targetUrl.toString(), {
method: 'HEAD', method: "HEAD",
mode: 'no-cors', mode: "no-cors",
cache: 'no-store', cache: "no-store",
signal: controller.signal, signal: controller.signal,
}) });
const latency = Math.round(performance.now() - start) const latency = Math.round(performance.now() - start);
clearTimeout(timeoutId) clearTimeout(timeoutId);
if (latency > CONFIG.maxLatency) return { latency: null, error: 'timeout' } if (latency > CONFIG.maxLatency)
return { latency, error: null } return { latency: null, error: "timeout" };
return { latency, error: null };
} catch (err) { } catch (err) {
clearTimeout(timeoutId) clearTimeout(timeoutId);
if (err.name === 'AbortError') return { latency: null, error: 'timeout' } if (err.name === "AbortError")
return { latency: null, error: 'unreachable' } return { latency: null, error: "timeout" };
return { latency: null, error: "unreachable" };
} }
} }
// --- Color Helpers ----------------------------------------------------------- // --- Color Helpers -----------------------------------------------------------
function latencyHex(latency) { function latencyHex(latency) {
if (latency === null) return '#6b7280' if (latency === null) return "#6b7280";
if (latency < 50) return '#22c55e' if (latency < 50) return "#22c55e";
if (latency < 100) return '#84cc16' if (latency < 100) return "#84cc16";
if (latency < 200) return '#eab308' if (latency < 200) return "#eab308";
if (latency < 500) return '#f97316' if (latency < 500) return "#f97316";
return '#ef4444' return "#ef4444";
} }
function latencyClass(latency, status) { function latencyClass(latency, status) {
if (status === 'offline' || status === 'error' || latency === null) return 'text-gray-500' if (status === "offline" || status === "error" || latency === null)
if (latency < 50) return 'text-green-500' return "text-gray-500";
if (latency < 100) return 'text-lime-500' if (latency < 50) return "text-green-500";
if (latency < 200) return 'text-yellow-500' if (latency < 100) return "text-lime-500";
if (latency < 500) return 'text-orange-500' if (latency < 200) return "text-yellow-500";
return 'text-red-500' if (latency < 500) return "text-orange-500";
return "text-red-500";
} }
// --- Sparkline Renderer ------------------------------------------------------ // --- Sparkline Renderer ------------------------------------------------------
class SparklineRenderer { 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) { static draw(canvas, history) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr const w = canvas.width / dpr;
const h = canvas.height / dpr const h = canvas.height / dpr;
const m = SparklineRenderer.MARGIN const m = SparklineRenderer.MARGIN;
const cw = w - m.left - m.right const cw = w - m.left - m.right;
const ch = h - m.top - m.bottom const ch = h - m.top - m.bottom;
ctx.clearRect(0, 0, w, h) ctx.clearRect(0, 0, w, h);
SparklineRenderer._drawYAxis(ctx, w, h, m, ch) SparklineRenderer._drawYAxis(ctx, w, h, m, ch);
SparklineRenderer._drawXAxis(ctx, w, h, m, cw) SparklineRenderer._drawXAxis(ctx, w, h, m, cw);
const len = history.length const len = history.length;
const pw = cw / (CONFIG.maxHistoryPoints - 1) const pw = cw / (CONFIG.maxHistoryPoints - 1);
const getX = i => m.left + cw - (len - 1 - i) * pw const getX = (i) => m.left + cw - (len - 1 - i) * pw;
const getY = lat => m.top + ch - (lat / CONFIG.maxLatency) * ch const getY = (lat) => m.top + ch - (lat / CONFIG.maxLatency) * ch;
SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch) SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch);
SparklineRenderer._drawLine(ctx, history, getX, getY) SparklineRenderer._drawLine(ctx, history, getX, getY);
SparklineRenderer._drawTip(ctx, history, getX, getY) SparklineRenderer._drawTip(ctx, history, getX, getY);
} }
static _drawYAxis(ctx, w, h, m, ch) { static _drawYAxis(ctx, w, h, m, ch) {
ctx.font = '9px monospace' ctx.font = "9px monospace";
ctx.textAlign = 'right' ctx.textAlign = "right";
ctx.textBaseline = 'middle' ctx.textBaseline = "middle";
for (const tick of CONFIG.yAxisTicks) { for (const tick of CONFIG.yAxisTicks) {
const y = m.top + ch - (tick / CONFIG.maxLatency) * ch const y = m.top + ch - (tick / CONFIG.maxLatency) * ch;
ctx.strokeStyle = 'rgba(255,255,255,0.1)' ctx.strokeStyle = "rgba(255,255,255,0.1)";
ctx.lineWidth = 1 ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(m.left, y); ctx.lineTo(w - m.right, y); ctx.stroke() ctx.beginPath();
ctx.fillStyle = 'rgba(255,255,255,0.5)' ctx.moveTo(m.left, y);
ctx.fillText(`${tick}`, m.left - 4, 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 _drawXAxis(ctx, w, h, m, cw) { static _drawXAxis(ctx, w, h, m, cw) {
ctx.textAlign = 'center' ctx.textAlign = "center";
ctx.textBaseline = 'top' ctx.textBaseline = "top";
for (const tick of CONFIG.xAxisTicks) { for (const tick of CONFIG.xAxisTicks) {
const x = m.left + cw - (tick / CONFIG.historyDuration) * cw const x = m.left + cw - (tick / CONFIG.historyDuration) * cw;
ctx.fillStyle = 'rgba(255,255,255,0.5)' ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.fillText(`-${tick}s`, x, h - m.bottom + 4) ctx.fillText(`-${tick}s`, x, h - m.bottom + 4);
} }
} }
static _drawErrors(ctx, history, getX, top, ch) { static _drawErrors(ctx, history, getX, top, ch) {
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)' ctx.fillStyle = "rgba(239, 68, 68, 0.2)";
let inErr = false, start = 0 let inErr = false,
start = 0;
for (let i = 0; i < history.length; i++) { for (let i = 0; i < history.length; i++) {
const p = history[i] const p = history[i];
const x = getX(i) const x = getX(i);
// Only real errors, not paused gaps // Only real errors, not paused gaps
const isError = p.latency === null && !p.paused const isError = p.latency === null && !p.paused;
if (isError && !inErr) { inErr = true; start = x } if (isError && !inErr) {
else if (!isError && inErr) { inErr = false; ctx.fillRect(start, top, x - start, ch) } 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) }
if (inErr)
ctx.fillRect(start, top, getX(history.length - 1) - start + 5, ch);
} }
static _drawLine(ctx, history, getX, getY) { static _drawLine(ctx, history, getX, getY) {
ctx.lineWidth = 2 ctx.lineWidth = 2;
ctx.lineCap = 'round' ctx.lineCap = "round";
ctx.lineJoin = 'round' ctx.lineJoin = "round";
let prev = null let prev = null;
for (let i = 0; i < history.length; i++) { for (let i = 0; i < history.length; i++) {
const p = history[i] const p = history[i];
if (p.latency === null) { prev = null; continue } if (p.latency === null) {
const x = getX(i), y = getY(p.latency) prev = null;
if (prev) { continue;
ctx.strokeStyle = latencyHex(p.latency)
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(x, y); ctx.stroke()
} }
prev = { x, y } 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 _drawTip(ctx, history, getX, getY) { static _drawTip(ctx, history, getX, getY) {
for (let i = history.length - 1; i >= 0; i--) { for (let i = history.length - 1; i >= 0; i--) {
if (history[i].latency !== null) { if (history[i].latency !== null) {
const x = getX(i), y = getY(history[i].latency) const x = getX(i),
ctx.fillStyle = latencyHex(history[i].latency) y = getY(history[i].latency);
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill() ctx.fillStyle = latencyHex(history[i].latency);
return ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
return;
} }
} }
} }
static sizeCanvas(canvas) { static sizeCanvas(canvas) {
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect() const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr canvas.width = rect.width * dpr;
canvas.height = CONFIG.canvasHeight * dpr canvas.height = CONFIG.canvasHeight * dpr;
const ctx = canvas.getContext('2d') const ctx = canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr) ctx.scale(dpr, dpr);
} }
} }
@ -275,11 +311,11 @@ function hostRowHTML(host, index) {
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas> <canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
</div> </div>
</div> </div>
</div>` </div>`;
} }
function buildUI(state) { function buildUI(state) {
const app = document.getElementById('app') const app = document.getElementById("app");
app.innerHTML = ` app.innerHTML = `
<div class="max-w-7xl mx-auto px-4 py-8"> <div class="max-w-7xl mx-auto px-4 py-8">
<header class="mb-8"> <header class="mb-8">
@ -317,12 +353,12 @@ function buildUI(state) {
</header> </header>
<div id="wan-hosts" class="space-y-4"> <div id="wan-hosts" class="space-y-4">
${state.wan.map((h, i) => hostRowHTML(h, i)).join('')} ${state.wan.map((h, i) => hostRowHTML(h, i)).join("")}
</div> </div>
<h2 class="text-gray-500 text-xs uppercase tracking-wide mt-6 mb-3">Local Network</h2> <h2 class="text-gray-500 text-xs uppercase tracking-wide mt-6 mb-3">Local Network</h2>
<div id="local-hosts" class="space-y-4"> <div id="local-hosts" class="space-y-4">
${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i)).join('')} ${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i)).join("")}
</div> </div>
<footer class="mt-8 text-center text-gray-600 text-xs"> <footer class="mt-8 text-center text-gray-600 text-xs">
@ -336,162 +372,192 @@ function buildUI(state) {
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline <span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p> </p>
</footer> </footer>
</div>` </div>`;
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.querySelectorAll('.sparkline-canvas').forEach(c => SparklineRenderer.sizeCanvas(c)) document
}) .querySelectorAll(".sparkline-canvas")
.forEach((c) => SparklineRenderer.sizeCanvas(c));
});
} }
// --- UI Updaters ------------------------------------------------------------- // --- UI Updaters -------------------------------------------------------------
function updateHostRow(host, index) { function updateHostRow(host, index) {
const latencyEl = document.querySelector(`.latency-value[data-host="${index}"]`) const latencyEl = document.querySelector(
const statusEl = document.querySelector(`.status-text[data-host="${index}"]`) `.latency-value[data-host="${index}"]`,
const canvas = document.querySelector(`.sparkline-canvas[data-host="${index}"]`) );
if (!latencyEl || !statusEl || !canvas) return 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) { if (host.lastLatency !== null) {
const cls = latencyClass(host.lastLatency, host.status) const cls = latencyClass(host.lastLatency, host.status);
latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>` latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
} else if (host.status === 'offline' || host.status === 'error') { } else if (host.status === "offline" || host.status === "error") {
latencyEl.innerHTML = `<span class="text-gray-500">---</span>` latencyEl.innerHTML = `<span class="text-gray-500">---</span>`;
} }
const avg = host.averageLatency() const avg = host.averageLatency();
if (host.status === 'online' && avg !== null) { if (host.status === "online" && avg !== null) {
statusEl.textContent = `avg: ${avg}ms` statusEl.textContent = `avg: ${avg}ms`;
statusEl.className = 'status-text text-xs text-gray-400 mt-1' statusEl.className = "status-text text-xs text-gray-400 mt-1";
} else if (host.status === 'offline') { } else if (host.status === "offline") {
statusEl.textContent = 'unreachable' statusEl.textContent = "unreachable";
statusEl.className = 'status-text text-xs text-red-400 mt-1' statusEl.className = "status-text text-xs text-red-400 mt-1";
} else if (host.status === 'error') { } else if (host.status === "error") {
statusEl.textContent = 'timeout' statusEl.textContent = "timeout";
statusEl.className = 'status-text text-xs text-orange-400 mt-1' statusEl.className = "status-text text-xs text-orange-400 mt-1";
} else { } else {
statusEl.textContent = 'connecting...' statusEl.textContent = "connecting...";
statusEl.className = 'status-text text-xs text-gray-500 mt-1' statusEl.className = "status-text text-xs text-gray-500 mt-1";
} }
SparklineRenderer.draw(canvas, host.history) SparklineRenderer.draw(canvas, host.history);
} }
function updateSummary(state) { function updateSummary(state) {
const stats = state.wanStats() const stats = state.wanStats();
const reachableEl = document.getElementById('summary-reachable') const reachableEl = document.getElementById("summary-reachable");
const minEl = document.getElementById('summary-min') const minEl = document.getElementById("summary-min");
const maxEl = document.getElementById('summary-max') const maxEl = document.getElementById("summary-max");
const avgEl = document.getElementById('summary-avg') const avgEl = document.getElementById("summary-avg");
if (!reachableEl) return if (!reachableEl) return;
reachableEl.textContent = `${stats.reachable}/${stats.total}` reachableEl.textContent = `${stats.reachable}/${stats.total}`;
reachableEl.className = stats.reachable === stats.total ? 'text-green-400' : reachableEl.className =
stats.reachable === 0 ? 'text-red-400' : 'text-yellow-400' stats.reachable === stats.total
? "text-green-400"
: stats.reachable === 0
? "text-red-400"
: "text-yellow-400";
if (stats.min !== null) { if (stats.min !== null) {
minEl.textContent = `${stats.min}ms`; minEl.className = latencyClass(stats.min, 'online') minEl.textContent = `${stats.min}ms`;
maxEl.textContent = `${stats.max}ms`; maxEl.className = latencyClass(stats.max, 'online') minEl.className = latencyClass(stats.min, "online");
avgEl.textContent = `${stats.avg}ms`; avgEl.className = latencyClass(stats.avg, 'online') maxEl.textContent = `${stats.max}ms`;
maxEl.className = latencyClass(stats.max, "online");
avgEl.textContent = `${stats.avg}ms`;
avgEl.className = latencyClass(stats.avg, "online");
} else { } else {
for (const el of [minEl, maxEl, avgEl]) { el.textContent = '--ms'; el.className = 'text-gray-500' } for (const el of [minEl, maxEl, avgEl]) {
el.textContent = "--ms";
el.className = "text-gray-500";
}
} }
} }
function updateHealthBox(state) { function updateHealthBox(state) {
const el = document.getElementById('health-text') const el = document.getElementById("health-text");
const box = document.getElementById('health-box') const box = document.getElementById("health-box");
if (!el || !box) return if (!el || !box) return;
const anyData = state.wan.some(h => h.status !== 'pending') const anyData = state.wan.some((h) => h.status !== "pending");
if (!anyData) return if (!anyData) return;
const healthy = state.isHealthy() const healthy = state.isHealthy();
el.textContent = healthy ? 'HEALTHY' : 'DEGRADED' el.textContent = healthy ? "HEALTHY" : "DEGRADED";
el.className = healthy ? 'text-green-400 font-bold' : 'text-red-400 font-bold' 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 ${ 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' healthy
}` ? "bg-green-900/20 border-green-700/50"
: "bg-red-900/20 border-red-700/50"
}`;
} }
// --- Main Loop --------------------------------------------------------------- // --- Main Loop ---------------------------------------------------------------
async function tick(state) { async function tick(state) {
const ts = Date.now() const ts = Date.now();
if (state.paused) { if (state.paused) {
// No probes — just push a paused marker so the chart keeps scrolling // No probes — just push a paused marker so the chart keeps scrolling
for (const host of state.allHosts) { for (const host of state.allHosts) {
host.pushPaused(ts) host.pushPaused(ts);
} }
// Redraw sparklines only // Redraw sparklines only
state.allHosts.forEach((host, i) => { state.allHosts.forEach((host, i) => {
const canvas = document.querySelector(`.sparkline-canvas[data-host="${i}"]`) const canvas = document.querySelector(
if (canvas) SparklineRenderer.draw(canvas, host.history) `.sparkline-canvas[data-host="${i}"]`,
}) );
return if (canvas) SparklineRenderer.draw(canvas, host.history);
});
return;
} }
const results = await Promise.all( const results = await Promise.all(
state.allHosts.map(h => measureLatency(h.url)) state.allHosts.map((h) => measureLatency(h.url)),
) );
state.allHosts.forEach((host, i) => { state.allHosts.forEach((host, i) => {
host.pushSample(ts, results[i]) host.pushSample(ts, results[i]);
updateHostRow(host, i) updateHostRow(host, i);
}) });
updateSummary(state) updateSummary(state);
updateHealthBox(state) updateHealthBox(state);
} }
// --- Pause / Resume ---------------------------------------------------------- // --- Pause / Resume ----------------------------------------------------------
function togglePause(state) { function togglePause(state) {
state.paused = !state.paused state.paused = !state.paused;
const pauseIcon = document.getElementById('pause-icon') const pauseIcon = document.getElementById("pause-icon");
const playIcon = document.getElementById('play-icon') const playIcon = document.getElementById("play-icon");
const pauseText = document.getElementById('pause-text') const pauseText = document.getElementById("pause-text");
const indicator = document.getElementById('status-indicator') const indicator = document.getElementById("status-indicator");
if (state.paused) { if (state.paused) {
pauseIcon.classList.add('hidden'); playIcon.classList.remove('hidden') pauseIcon.classList.add("hidden");
pauseText.textContent = 'Resume' playIcon.classList.remove("hidden");
indicator.textContent = 'Paused'; indicator.className = 'text-yellow-400' pauseText.textContent = "Resume";
indicator.textContent = "Paused";
indicator.className = "text-yellow-400";
} else { } else {
pauseIcon.classList.remove('hidden'); playIcon.classList.add('hidden') pauseIcon.classList.remove("hidden");
pauseText.textContent = 'Pause' playIcon.classList.add("hidden");
indicator.textContent = 'Running'; indicator.className = 'text-green-400' pauseText.textContent = "Pause";
indicator.textContent = "Running";
indicator.className = "text-green-400";
} }
} }
// --- Resize ------------------------------------------------------------------ // --- Resize ------------------------------------------------------------------
function handleResize(state) { function handleResize(state) {
document.querySelectorAll('.sparkline-canvas').forEach((canvas, i) => { document.querySelectorAll(".sparkline-canvas").forEach((canvas, i) => {
SparklineRenderer.sizeCanvas(canvas) SparklineRenderer.sizeCanvas(canvas);
const host = state.allHosts[i] const host = state.allHosts[i];
if (host) SparklineRenderer.draw(canvas, host.history) if (host) SparklineRenderer.draw(canvas, host.history);
}) });
} }
// --- Bootstrap --------------------------------------------------------------- // --- Bootstrap ---------------------------------------------------------------
function init() { function init() {
const state = new AppState() const state = new AppState();
buildUI(state) buildUI(state);
document.getElementById('pause-btn').addEventListener('click', () => togglePause(state)) document
.getElementById("pause-btn")
.addEventListener("click", () => togglePause(state));
tick(state) tick(state);
setInterval(() => tick(state), CONFIG.updateInterval) setInterval(() => tick(state), CONFIG.updateInterval);
window.addEventListener('resize', () => handleResize(state)) window.addEventListener("resize", () => handleResize(state));
setTimeout(() => handleResize(state), 100) setTimeout(() => handleResize(state), 100);
} }
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init) document.addEventListener("DOMContentLoaded", init);
} else { } else {
init() init();
} }

View File

@ -10,9 +10,14 @@
} }
body { 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 { .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%
);
} }

View File

@ -1,11 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import tailwindcss from '@tailwindcss/vite' import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
build: { build: {
target: 'esnext', target: "esnext",
minify: 'esbuild', minify: "esbuild",
cssMinify: true, cssMinify: true,
}, },
}) });

View File

@ -668,6 +668,11 @@ postcss@^8.5.6:
picocolors "^1.1.1" picocolors "^1.1.1"
source-map-js "^1.2.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: rollup@^4.43.0:
version "4.57.0" version "4.57.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"