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
.DS_Store
*.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
# 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;'"]

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

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,13 +1,17 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>" />
</head>
<body class="bg-gray-900 text-white min-h-screen">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>"
/>
</head>
<body class="bg-gray-900 text-white min-h-screen">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -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"
}
}

View File

@ -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 `
<div class="host-row bg-gray-800/50 rounded-lg p-4 border border-gray-700/50" data-index="${index}">
<div class="flex items-center gap-4">
<div class="w-48 flex-shrink-0">
@ -275,12 +311,12 @@ function hostRowHTML(host, index) {
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
</div>
</div>
</div>`
</div>`;
}
function buildUI(state) {
const app = document.getElementById('app')
app.innerHTML = `
const app = document.getElementById("app");
app.innerHTML = `
<div class="max-w-7xl mx-auto px-4 py-8">
<header class="mb-8">
<div class="flex items-center justify-between mb-4">
@ -317,12 +353,12 @@ function buildUI(state) {
</header>
<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>
<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">
${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>
<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
</p>
</footer>
</div>`
</div>`;
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 = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`
} else if (host.status === 'offline' || host.status === 'error') {
latencyEl.innerHTML = `<span class="text-gray-500">---</span>`
}
if (host.lastLatency !== null) {
const cls = latencyClass(host.lastLatency, host.status);
latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
} else if (host.status === "offline" || host.status === "error") {
latencyEl.innerHTML = `<span class="text-gray-500">---</span>`;
}
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();
}

View File

@ -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%
);
}

View File

@ -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,
},
});

View File

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