Compare commits

...

19 Commits

Author SHA1 Message Date
f4517ae953 Redesign summary box and add host pinning
Summary now shows current min/avg/max and history-window min/max.
Each host row has a pin icon that pins it to the top. Pinned hosts
sort alphabetically, unpinned sort by latency. datavi.be is pinned
by default.
2026-02-23 01:13:04 +07:00
a3bd3d06d1 Add local and UTC clocks below header, updated every second 2026-02-23 01:06:48 +07:00
77e40bf4ef Add Checks counter to summary line 2026-02-23 01:05:10 +07:00
d896c2d19b Sort WAN hosts by latency after first check and every 5 ticks
datavi.be stays pinned at top. Remaining hosts sort ascending by
last latency, with unreachable hosts at the bottom.
2026-02-23 01:04:15 +07:00
0bbf4d66a8 Switch from HEAD to GET for latency measurement
Hetzner speed test servers drop the connection on HEAD requests,
causing fetch to throw a network error. GET works universally and
with no-cors mode the response is opaque anyway.
2026-02-23 01:01:50 +07:00
60372f0708 Derive request timeout from update interval
requestTimeout is now updateInterval - 50ms, ensuring requests
complete before the next tick. maxLatency matches requestTimeout.
2026-02-23 00:59:29 +07:00
4aaf9c2a49 Replace GCS endpoints with Hetzner regional, change interval to 3s
GCS locational endpoints were too slow (>1500ms). Replace with 6
Hetzner speed test servers (Nuremberg DE, Falkenstein DE, Helsinki
FI, Ashburn VA-US, Hillsboro OR-US, Singapore SG) which are genuine
per-DC HTTPS endpoints. Bump update interval from 2s to 3s.
2026-02-23 00:55:06 +07:00
c31b976f01 Widen host name column from w-48 to w-72
Prevents truncation of longer endpoint names like the S3/GCS
regional identifiers.
2026-02-23 00:43:59 +07:00
869f123a5b Shorten storage endpoint display names and drop continent labels
Remove verbose continent labels from S3 names, shorten GCS region
codes (us-cent1, eu-west1, asia-se1, aus-se1) to fit the name column.
2026-02-23 00:42:15 +07:00
ca67f65242 Set page side margins to 10% for balanced layout 2026-02-23 00:39:22 +07:00
bc612daf22 Remove max-width cap so host wells fill the viewport
The max-w-7xl (1280px) constraint left too much dead space between
the host wells and the window edges. Remove it so the layout uses
all available width.
2026-02-23 00:38:20 +07:00
a3feacb842 Reduce page side margins by 50%
px-4 (1rem) to px-2 (0.5rem) to give more horizontal space for
long host names.
2026-02-23 00:36:58 +07:00
94169b8d65 Add S3, GCS, and B2 regional storage endpoints
Remove DigitalOcean. Add B2, 7 S3 endpoints across all continents
(Cape Town, London, Bahrain, Tokyo, Sydney, Oregon, São Paulo), and
4 GCS locational endpoints (Iowa, Belgium, Singapore, Sydney) for
cross-provider latency comparison.
2026-02-23 00:35:07 +07:00
14764a79ad Color-code per-host avg label and clamp graph Y-axis to 1000ms
The avg latency text below each host's big number is now color-coded
using the same thresholds as the main figure. The sparkline Y-axis
stays 0-1000ms — values between 1000-1500ms pin to the top of the
chart but still show their real value in the latency display.
2026-02-23 00:26:00 +07:00
55fb63bec1 Increase request timeout and max latency to 1500ms
For high-latency connections, 1000ms was too aggressive and caused
false unreachable readings. Bump to 1500ms and add a 1500 tick to
the Y-axis.
2026-02-23 00:23:43 +07:00
0e84b973ce Add make dev target for running the dev server 2026-02-23 00:17:36 +07:00
b23da0797e Add gateway auto-detection and rename to Local CPE
Local CPE (192.168.100.1) is always monitored. On startup, probe
192.168.1.1, 192.168.0.1, 192.168.8.1, and 10.0.0.1 in parallel
and add whichever responds first as "Local Gateway".
2026-02-23 00:05:48 +07:00
83bd23945c Add Anthropic and OpenAI API endpoints to monitoring targets
Reorder host list: datavi.be first, then LLM APIs, then cloud
providers (AWS, GCP, Azure), then CDN/hosting, then GitHub.
2026-02-23 00:01:50 +07:00
cb8d47d7aa Enable prettier prose wrapping for markdown
Add proseWrap: "always" to .prettierrc so markdown prose is
hard-wrapped at 80 columns.
2026-02-23 00:00:25 +07:00
5 changed files with 376 additions and 97 deletions

View File

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

View File

@ -1,4 +1,7 @@
.PHONY: test lint fmt fmt-check check docker .PHONY: dev test lint fmt fmt-check check docker
dev:
yarn dev
test: test:
timeout 30 yarn build timeout 30 yarn build

View File

@ -1,4 +1,7 @@
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. 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.
## Getting Started ## Getting Started
@ -22,27 +25,50 @@ docker run -p 8080:8080 netwatch
## Rationale ## Rationale
When debugging network issues, it's useful to have a persistent at-a-glance view of latency and reachability to multiple well-known internet endpoints. NetWatch provides this as a zero-dependency SPA that can be deployed anywhere static files are served, with no backend required. When debugging network issues, it's useful to have a persistent at-a-glance view
of latency and reachability to multiple well-known internet endpoints. NetWatch
provides this as a zero-dependency SPA that can be deployed anywhere static
files are served, with no backend required.
## Design ## Design
The application is a single-page app built with Vite and Tailwind CSS v4. All code lives in `src/main.js` with a class-based architecture: The application is a single-page app built with Vite and Tailwind CSS v4. All
code lives in `src/main.js` with a class-based architecture:
- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis ticks, etc.) - **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis
- **`HostState`**: Per-host state management — history buffer, latency tracking, status transitions ticks, etc.)
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause state, aggregate stats - **`HostState`**: Per-host state management — history buffer, latency tracking,
- **`SparklineRenderer`**: Canvas 2D sparkline drawing with fixed axes, color-coded line segments, error regions, and DPR-aware scaling status transitions
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` / `updateSummary()` / `updateHealthBox()` handle incremental updates - **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
- **`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) 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)
### Monitoring targets ### Monitoring targets
- **9 WAN hosts**: Google Cloud Console, AWS Console, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, datavi.be - **22 WAN hosts**: datavi.be, Anthropic API, OpenAI API, AWS Console, GCP
- **1 Local host**: Local Gateway (192.168.100.1), tracked separately from WAN stats Console, Azure, Cloudflare, Fastly, Akamai, GitHub, B2, 7 S3 regional
endpoints (Cape Town, London, Bahrain, Tokyo, Sydney, Oregon, São Paulo), 4
GCS locational endpoints (Iowa, Belgium, Singapore, Sydney)
- **Local CPE**: Cable modem at 192.168.100.1 (always monitored)
- **Local Gateway**: Auto-detected on startup by probing common default gateway
addresses (192.168.1.1, 192.168.0.1, 192.168.8.1, 10.0.0.1); first responder
wins. Note: modern browsers enforce Private Network Access restrictions that
block public-origin pages from reaching RFC1918 addresses, so local targets
only work when NetWatch is served from localhost or a private address.
Local hosts are tracked separately from WAN stats.
### Latency measurement ### 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. 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 ### Color coding
@ -72,31 +98,40 @@ dist/
- Summary stats: reachable count, min/max/avg latency across WAN hosts only - Summary stats: reachable count, min/max/avg latency across WAN hosts only
- Fixed chart axes: Y-axis 01000ms, X-axis 0300s - Fixed chart axes: Y-axis 01000ms, X-axis 0300s
- Color-coded latency figures and sparkline line segments - Color-coded latency figures and sparkline line segments
- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no false outage) - Play/pause: pause stops probes but history keeps scrolling (blank gaps, no
false outage)
- Clickable service URLs - Clickable service URLs
- Canvas-based sparkline rendering with devicePixelRatio scaling - Canvas-based sparkline rendering with devicePixelRatio scaling
- Zero runtime dependencies: all resources bundled into build artifacts - Zero runtime dependencies: all resources bundled into build artifacts
## Deployment ## 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. 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: The Docker image:
- Listens on port 8080 by default (override with `PORT` env var) - 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) - Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12,
192.168/16)
- Sends access logs to stdout - Sends access logs to stdout
- Caches static assets with immutable headers - Caches static assets with immutable headers
## Browser Compatibility ## Browser Compatibility
Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom properties. Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom
properties.
## Limitations ## Limitations
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses `no-cors` mode which allows the request but provides opaque responses. Latency is still measurable based on request timing. - **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
- **Local gateway**: The 192.168.100.1 endpoint requires the host to be accessible from your network. `no-cors` mode which allows the request but provides opaque responses. Latency
- **Network conditions**: Measurements reflect browser-to-endpoint latency, which includes your local network, ISP, and internet routing. is still measurable based on request timing.
- **Local gateway**: The 192.168.100.1 endpoint requires the host to be
accessible from your network.
- **Network conditions**: Measurements reflect browser-to-endpoint latency,
which includes your local network, ISP, and internet routing.
## TODO ## TODO

View File

@ -1,23 +1,22 @@
# Development Policies # Development Policies
- Docker image references by tag are server-mutable, therefore using them is - Docker image references by tag are server-mutable, therefore using them is an
an RCE vulnerability. All docker image references must use cryptographic RCE vulnerability. All docker image references must use cryptographic hashes
hashes to securely specify the exact image that is expected. to securely specify the exact image that is expected.
- Correspondingly, `go install` commands using things like '@latest' are - Correspondingly, `go install` commands using things like '@latest' are also
also dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go install
install targets using commit hashes which are cryptographically secure. targets using commit hashes which are cryptographically secure.
- Every repo with software in it must have a Makefile in the root. Each - Every repo with software in it must have a Makefile in the root. Each such
such Makefile should support `make test` (runs the project-specific Makefile should support `make test` (runs the project-specific tests),
tests), `make lint`, `make fmt` (writes), `make fmt-check` (readonly), and `make lint`, `make fmt` (writes), `make fmt-check` (readonly), and
`make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make `make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make docker`
docker` (builds docker image). (builds docker image).
- Every repo should have a Dockerfile. If the repo contains non-server - Every repo should have a Dockerfile. If the repo contains non-server software,
software, the Dockerfile should bring up a development environment and the Dockerfile should bring up a development environment and `make check`
`make check` (i.e. the docker build should fail if the branch is not (i.e. the docker build should fail if the branch is not green).
green).
- Platform-specific standard formatting should be used. `black` for python, - Platform-specific standard formatting should be used. `black` for python,
`prettier` for js/css/etc, `go fmt` for go. The only changes to default `prettier` for js/css/etc, `go fmt` for go. The only changes to default
@ -25,27 +24,27 @@ docker` (builds docker image).
everything except `go fmt`). everything except `go fmt`).
- If local testing is possible (it is not always), `make check` should be a - 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` pre-commit hook. If it is not possible, `make lint && make fmt-check` should
should be a pre-commit hook. be a pre-commit hook.
- If a working `make test` takes more than 20 seconds, that's a bug that - If a working `make test` takes more than 20 seconds, that's a bug that needs
needs fixing. In fact, there should be a timeout specified in the fixing. In fact, there should be a timeout specified in the `Makefile` that
`Makefile` that fails it automatically if it takes >30s. fails it automatically if it takes >30s.
- Docker builds should time out in 5 minutes or less. - Docker builds should time out in 5 minutes or less.
- `main` must always pass `make check`, no exceptions. - `main` must always pass `make check`, no exceptions.
- Do all changes on a feature branch. You can do whatever you want on a - Do all changes on a feature branch. You can do whatever you want on a feature
feature branch. branch.
- We have a standardized `.golangci.yml` which we reuse and is _NEVER_ to be - 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 modified by an agent, only manually by the user. It can be copied from
`~/dev/upaas/.golangci.yml` if it exists at that location. `~/dev/upaas/.golangci.yml` if it exists at that location.
- When specifying images or packages by hash in Dockerfiles or - When specifying images or packages by hash in Dockerfiles or
`docker-compose.yml`, put a comment above the line and show the version `docker-compose.yml`, put a comment above the line and show the version and
and date at which it was current. date at which it was current.
- For javascript, always use `yarn` over `npm`. - For javascript, always use `yarn` over `npm`.
@ -54,11 +53,11 @@ docker` (builds docker image).
- Simple projects should be configured with environment variables, as is - Simple projects should be configured with environment variables, as is
standard for Dockerized applications. standard for Dockerized applications.
- Dockerized web services should listen on the default HTTP port of 8080 - Dockerized web services should listen on the default HTTP port of 8080 unless
unless overridden with the `PORT` environment variable. overridden with the `PORT` environment variable.
- The `README.md` is a project's primary documentation. It should contain - The `README.md` is a project's primary documentation. It should contain at a
at a minimum the following sections: minimum the following sections:
- Description - Description
- Include a short and complete description of the functionality and - Include a short and complete description of the functionality and
purpose of the software as the first line in the readme. It must purpose of the software as the first line in the readme. It must
@ -68,10 +67,10 @@ docker` (builds docker image).
- the category (web server, SPA, command line tool, etc) - the category (web server, SPA, command line tool, etc)
- the license - the license
- the author - the author
- eg: "µPaaS is an MIT-licensed Go web application by @sneak - eg: "µPaaS is an MIT-licensed Go web application by @sneak that
that receives git-frontend webhooks and interacts with a receives git-frontend webhooks and interacts with a Docker server
Docker server to build and deploy applications in realtime as to build and deploy applications in realtime as certain branches
certain branches are updated." are updated."
- Getting Started - Getting Started
- a code block with copy-pasteable installation/use sections - a code block with copy-pasteable installation/use sections
- Rationale - Rationale
@ -79,28 +78,27 @@ docker` (builds docker image).
- Design - Design
- how is the program structured? - how is the program structured?
- TODO - TODO
- This is your TODO list for the project - update it meticulously, - This is your TODO list for the project - update it meticulously, even
even in between commits. Whenever planning, put your todo list in in between commits. Whenever planning, put your todo list in the
the README so that a separate agent with new context can pick up README so that a separate agent with new context can pick up where you
where you left off. left off.
- License - License
- GPL or MIT or WTFPL - ask the user when beginning a new project - GPL or MIT or WTFPL - ask the user when beginning a new project and
and include a LICENSE file in the root and in a section in the include a LICENSE file in the root and in a section in the README.
README.
- Author - Author
- @sneak (link `@sneak` to `https://sneak.berlin`). - @sneak (link `@sneak` to `https://sneak.berlin`).
- When beginning a new project, initialize a git repo and make the first - When beginning a new project, initialize a git repo and make the first commit
commit simply the first version of the README.md in the root of the repo. 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 - For Go packages, the module root is `sneak.berlin/go/...`, such as
as `sneak.berlin/go/dnswatcher`. `sneak.berlin/go/dnswatcher`.
- We use SemVer always. - We use SemVer always.
- If no tag `1.0.0` or greater exists in the repository, modify the existing - 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 migrations and assume no installed base or existing databases. If `>=1.0.0`,
`>=1.0.0`, database changes add new migration files. database changes add new migration files.
- New repos must have at a minimum the following files: - New repos must have at a minimum the following files:
- `README.md`, `.git`, `.gitignore` - `README.md`, `.git`, `.gitignore`

View File

@ -2,11 +2,21 @@ import "./styles.css";
// --- Configuration ----------------------------------------------------------- // --- Configuration -----------------------------------------------------------
// Timing, axis labels, and display constants. Latency above maxLatency is
// clamped to "unreachable". The sparkline Y-axis is capped at
// graphMaxLatency — values above it pin to the top of the chart but still
// display their real value in the latency figure. The history buffer holds
// maxHistoryPoints samples (historyDuration / updateInterval).
const CONFIG = Object.freeze({ const CONFIG = Object.freeze({
updateInterval: 2000, updateInterval: 3000,
historyDuration: 300, historyDuration: 300,
requestTimeout: 1000, get requestTimeout() {
maxLatency: 1000, return this.updateInterval - 50;
},
get maxLatency() {
return this.requestTimeout;
},
graphMaxLatency: 1000,
yAxisTicks: [0, 250, 500, 750, 1000], yAxisTicks: [0, 250, 500, 750, 1000],
xAxisTicks: [0, 60, 120, 180, 240, 300], xAxisTicks: [0, 60, 120, 180, 240, 300],
canvasHeight: 80, canvasHeight: 80,
@ -15,29 +25,136 @@ const CONFIG = Object.freeze({
}, },
}); });
// WAN endpoints to monitor. These are used for the aggregate health/stats
// display (min/max/avg). Ordered: personal, LLM APIs, big-3 cloud, then
// CDN/hosting/other.
const WAN_HOSTS = [ const WAN_HOSTS = [
{ name: "Google Cloud Console", url: "https://console.cloud.google.com" }, { name: "datavi.be", url: "https://datavi.be" },
{ name: "Anthropic API", url: "https://api.anthropic.com" },
{ name: "OpenAI API", url: "https://api.openai.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: "Google Cloud Console", url: "https://console.cloud.google.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: "Cloudflare", url: "https://www.cloudflare.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: "GitHub", url: "https://github.com" },
{ name: "B2", url: "https://api.backblazeb2.com" },
{
name: "S3 af-south-1 (Cape Town)",
url: "https://s3.af-south-1.amazonaws.com",
},
{
name: "S3 eu-west-2 (London)",
url: "https://s3.eu-west-2.amazonaws.com",
},
{
name: "S3 me-south-1 (Bahrain)",
url: "https://s3.me-south-1.amazonaws.com",
},
{
name: "S3 ap-northeast-1 (Tokyo)",
url: "https://s3.ap-northeast-1.amazonaws.com",
},
{
name: "S3 ap-southeast-2 (Sydney)",
url: "https://s3.ap-southeast-2.amazonaws.com",
},
{
name: "S3 us-west-2 (Oregon)",
url: "https://s3.us-west-2.amazonaws.com",
},
{
name: "S3 sa-east-1 (São Paulo)",
url: "https://s3.sa-east-1.amazonaws.com",
},
// Hetzner regional speed test servers — genuine per-DC endpoints
{
name: "Hetzner nbg1 (Nuremberg DE)",
url: "https://nbg1-speed.hetzner.com",
},
{
name: "Hetzner fsn1 (Falkenstein DE)",
url: "https://fsn1-speed.hetzner.com",
},
{
name: "Hetzner hel1 (Helsinki FI)",
url: "https://hel1-speed.hetzner.com",
},
{
name: "Hetzner ash (Ashburn VA-US)",
url: "https://ash-speed.hetzner.com",
},
{
name: "Hetzner hil (Hillsboro OR-US)",
url: "https://hil-speed.hetzner.com",
},
{
name: "Hetzner sin (Singapore SG)",
url: "https://sin-speed.hetzner.com",
},
]; ];
const LOCAL_HOSTS = [{ name: "Local Gateway", url: "http://192.168.100.1" }]; // The cable modem / CPE upstream of the local gateway — always monitored.
const LOCAL_CPE = { name: "Local CPE", url: "http://192.168.100.1" };
// Common default gateway addresses. On startup we probe each one and use
// whichever responds first as the "Local Gateway" monitor target.
//
// NOTE: Modern browsers enforce Private Network Access (PNA) restrictions
// that block pages served from public origins from making requests to
// RFC1918 addresses. These local targets will likely only work when
// NetWatch is served from localhost or another private address.
const GATEWAY_CANDIDATES = [
"http://192.168.1.1",
"http://192.168.0.1",
"http://192.168.8.1",
"http://10.0.0.1",
];
// --- Gateway Detection -------------------------------------------------------
// Probe each gateway candidate with a short timeout. Returns the first one
// that responds, or null if none do. We race them all in parallel and take
// whichever wins.
async function detectGateway() {
try {
const result = await Promise.any(
GATEWAY_CANDIDATES.map(async (url) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
try {
await fetch(url, {
method: "GET",
mode: "no-cors",
cache: "no-store",
signal: controller.signal,
});
clearTimeout(timeoutId);
return { name: "Local Gateway", url };
} catch {
clearTimeout(timeoutId);
throw new Error("no response");
}
}),
);
return result;
} catch {
// All candidates failed
return null;
}
}
// --- App State --------------------------------------------------------------- // --- App State ---------------------------------------------------------------
class HostState { class HostState {
constructor(host) { constructor(host, pinned = false) {
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'
this.pinned = pinned;
} }
pushSample(timestamp, result) { pushSample(timestamp, result) {
@ -73,10 +190,13 @@ class HostState {
} }
class AppState { class AppState {
constructor() { constructor(localHosts) {
this.wan = WAN_HOSTS.map((h) => new HostState(h)); this.wan = WAN_HOSTS.map(
this.local = LOCAL_HOSTS.map((h) => new HostState(h)); (h) => new HostState(h, h.name === "datavi.be"),
);
this.local = localHosts.map((h) => new HostState(h));
this.paused = false; this.paused = false;
this.tickCount = 0;
} }
get allHosts() { get allHosts() {
@ -101,6 +221,22 @@ class AppState {
}; };
} }
/** Min/max across all WAN host history (the full 300s window) */
wanHistoryStats() {
let min = Infinity;
let max = -Infinity;
for (const host of this.wan) {
for (const p of host.history) {
if (p.latency !== null) {
if (p.latency < min) min = p.latency;
if (p.latency > max) max = p.latency;
}
}
}
if (min === Infinity) return { min: null, max: null };
return { min, max };
}
/** 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;
@ -124,7 +260,7 @@ async function measureLatency(url) {
try { try {
await fetch(targetUrl.toString(), { await fetch(targetUrl.toString(), {
method: "HEAD", method: "GET",
mode: "no-cors", mode: "no-cors",
cache: "no-store", cache: "no-store",
signal: controller.signal, signal: controller.signal,
@ -184,7 +320,11 @@ class SparklineRenderer {
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 -
(Math.min(lat, CONFIG.graphMaxLatency) / CONFIG.graphMaxLatency) *
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);
@ -196,7 +336,7 @@ class SparklineRenderer {
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.graphMaxLatency) * 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.beginPath();
@ -291,10 +431,18 @@ class SparklineRenderer {
// --- UI Renderer ------------------------------------------------------------- // --- UI Renderer -------------------------------------------------------------
function hostRowHTML(host, index) { function hostRowHTML(host, index) {
const pinColor = host.pinned
? "text-blue-400"
: "text-gray-600 hover:text-gray-400";
return ` return `
<div class="host-row bg-gray-800/50 rounded-lg p-4 border border-gray-700/50" data-index="${index}"> <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="flex items-center gap-4">
<div class="w-48 flex-shrink-0"> <button class="pin-btn flex-shrink-0 ${pinColor} transition-colors" data-pin="${index}" title="Pin to top">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16">
<path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5a.5.5 0 0 1-1 0V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354"/>
</svg>
</button>
<div class="w-72 flex-shrink-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: ${latencyHex(null)}"></div> <div class="w-3 h-3 rounded-full" style="background-color: ${latencyHex(null)}"></div>
<span class="font-medium text-white truncate">${host.name}</span> <span class="font-medium text-white truncate">${host.name}</span>
@ -317,7 +465,7 @@ function hostRowHTML(host, index) {
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="mx-auto px-[10%] py-8">
<header class="mb-8"> <header class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">NetWatch</h1> <h1 class="text-3xl font-bold text-white">NetWatch</h1>
@ -338,17 +486,22 @@ function buildUI(state) {
<span class="text-gray-500">History: ${CONFIG.historyDuration}s</span> | <span class="text-gray-500">History: ${CONFIG.historyDuration}s</span> |
<span id="status-indicator" class="text-green-400">Running</span> <span id="status-indicator" class="text-green-400">Running</span>
</p> </p>
<p class="text-gray-500 text-xs font-mono mt-1">
<span id="clock-local"></span> | <span id="clock-utc"></span>
</p>
<div id="health-box" class="mt-4 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm text-center"> <div id="health-box" class="mt-4 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm text-center">
<span id="health-text" class="text-gray-400">Waiting for data...</span> <span id="health-text" class="text-gray-400">Waiting for data...</span>
</div> </div>
<div id="summary" class="mt-2 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm"> <div id="summary" class="mt-2 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm">
<span class="text-gray-400">Reachable:</span> <span id="summary-reachable" class="text-white">--/--</span> <span class="text-gray-400">Reachable:</span> <span id="summary-reachable" class="text-white">--/--</span>
<span class="text-gray-600 mx-3">|</span> <span class="text-gray-600 mx-3">|</span>
<span class="text-gray-400">Min:</span> <span id="summary-min" class="text-green-400">--ms</span> <span class="text-gray-400">Now:</span>
<span id="summary-min" class="text-green-400">--</span>/<span id="summary-avg" class="text-yellow-400">--</span>/<span id="summary-max" class="text-red-400">--ms</span>
<span class="text-gray-600 mx-3">|</span> <span class="text-gray-600 mx-3">|</span>
<span class="text-gray-400">Max:</span> <span id="summary-max" class="text-red-400">--ms</span> <span class="text-gray-400">${CONFIG.historyDuration}s:</span>
<span id="summary-hmin" class="text-green-400">--</span>/<span id="summary-hmax" class="text-red-400">--ms</span>
<span class="text-gray-600 mx-3">|</span> <span class="text-gray-600 mx-3">|</span>
<span class="text-gray-400">Avg:</span> <span id="summary-avg" class="text-yellow-400">--ms</span> <span class="text-gray-400">Checks:</span> <span id="summary-checks" class="text-gray-300">0</span>
</div> </div>
</header> </header>
@ -405,7 +558,7 @@ function updateHostRow(host, index) {
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 mt-1 ${latencyClass(avg, "online")}`;
} 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";
@ -422,11 +575,14 @@ function updateHostRow(host, index) {
function updateSummary(state) { function updateSummary(state) {
const stats = state.wanStats(); const stats = state.wanStats();
const hstats = state.wanHistoryStats();
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");
const hminEl = document.getElementById("summary-hmin");
const hmaxEl = document.getElementById("summary-hmax");
if (!reachableEl) return; if (!reachableEl) return;
reachableEl.textContent = `${stats.reachable}/${stats.total}`; reachableEl.textContent = `${stats.reachable}/${stats.total}`;
@ -438,18 +594,35 @@ function updateSummary(state) {
: "text-yellow-400"; : "text-yellow-400";
if (stats.min !== null) { if (stats.min !== null) {
minEl.textContent = `${stats.min}ms`; minEl.textContent = `${stats.min}`;
minEl.className = latencyClass(stats.min, "online"); minEl.className = latencyClass(stats.min, "online");
avgEl.textContent = `${stats.avg}`;
avgEl.className = latencyClass(stats.avg, "online");
maxEl.textContent = `${stats.max}ms`; maxEl.textContent = `${stats.max}ms`;
maxEl.className = latencyClass(stats.max, "online"); 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]) { minEl.textContent = "--";
el.textContent = "--ms"; minEl.className = "text-gray-500";
el.className = "text-gray-500"; avgEl.textContent = "--";
} avgEl.className = "text-gray-500";
maxEl.textContent = "--ms";
maxEl.className = "text-gray-500";
} }
if (hstats.min !== null) {
hminEl.textContent = `${hstats.min}`;
hminEl.className = latencyClass(hstats.min, "online");
hmaxEl.textContent = `${hstats.max}ms`;
hmaxEl.className = latencyClass(hstats.max, "online");
} else {
hminEl.textContent = "--";
hminEl.className = "text-gray-500";
hmaxEl.textContent = "--ms";
hmaxEl.className = "text-gray-500";
}
const checksEl = document.getElementById("summary-checks");
if (checksEl) checksEl.textContent = state.tickCount;
} }
function updateHealthBox(state) { function updateHealthBox(state) {
@ -472,6 +645,43 @@ function updateHealthBox(state) {
}`; }`;
} }
// --- Sorting -----------------------------------------------------------------
// Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts
// by last latency ascending, with unreachable hosts at the bottom.
function sortAndRebuildWAN(state) {
const pinned = state.wan
.filter((h) => h.pinned)
.sort((a, b) => a.name.localeCompare(b.name));
const rest = state.wan.filter((h) => !h.pinned);
rest.sort((a, b) => {
if (a.lastLatency === null && b.lastLatency === null) return 0;
if (a.lastLatency === null) return 1;
if (b.lastLatency === null) return -1;
return a.lastLatency - b.lastLatency;
});
state.wan = [...pinned, ...rest];
const container = document.getElementById("wan-hosts");
container.innerHTML = state.wan.map((h, i) => hostRowHTML(h, i)).join("");
// Re-index local hosts after WAN
const localContainer = document.getElementById("local-hosts");
localContainer.innerHTML = state.local
.map((h, i) => hostRowHTML(h, state.wan.length + i))
.join("");
// Resize canvases and redraw
requestAnimationFrame(() => {
document.querySelectorAll(".sparkline-canvas").forEach((canvas) => {
SparklineRenderer.sizeCanvas(canvas);
});
state.allHosts.forEach((host, i) => {
updateHostRow(host, i);
});
});
}
// --- Main Loop --------------------------------------------------------------- // --- Main Loop ---------------------------------------------------------------
async function tick(state) { async function tick(state) {
@ -501,6 +711,12 @@ async function tick(state) {
updateHostRow(host, i); updateHostRow(host, i);
}); });
state.tickCount++;
// Sort after the first check, then every 5 checks thereafter
if (state.tickCount === 1 || state.tickCount % 5 === 1) {
sortAndRebuildWAN(state);
}
updateSummary(state); updateSummary(state);
updateHealthBox(state); updateHealthBox(state);
} }
@ -541,14 +757,40 @@ function handleResize(state) {
// --- Bootstrap --------------------------------------------------------------- // --- Bootstrap ---------------------------------------------------------------
function init() { async function init() {
const state = new AppState(); // Probe common gateway IPs to find the local router
const gateway = await detectGateway();
const localHosts = [LOCAL_CPE];
if (gateway) localHosts.push(gateway);
const state = new AppState(localHosts);
buildUI(state); buildUI(state);
document document
.getElementById("pause-btn") .getElementById("pause-btn")
.addEventListener("click", () => togglePause(state)); .addEventListener("click", () => togglePause(state));
// Pin button clicks — use event delegation so it survives DOM rebuilds
document.addEventListener("click", (e) => {
const btn = e.target.closest(".pin-btn");
if (!btn) return;
const idx = parseInt(btn.dataset.pin, 10);
const host = state.wan[idx];
if (!host) return;
host.pinned = !host.pinned;
sortAndRebuildWAN(state);
});
function updateClocks() {
const now = new Date();
const utc = now.toISOString().replace(/\.\d{3}Z$/, "Z");
const local = now.toLocaleString("sv-SE", { hour12: false });
document.getElementById("clock-local").textContent = "Local: " + local;
document.getElementById("clock-utc").textContent = "UTC: " + utc;
}
updateClocks();
setInterval(updateClocks, 1000);
tick(state); tick(state);
setInterval(() => tick(state), CONFIG.updateInterval); setInterval(() => tick(state), CONFIG.updateInterval);