Compare commits

..

No commits in common. "main" and "feature/ui-improvements-and-docker" have entirely different histories.

5 changed files with 97 additions and 376 deletions

View File

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

View File

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

View File

@ -1,7 +1,4 @@
NetWatch is an MIT-licensed JavaScript single-page application by 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.
[@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
@ -25,50 +22,27 @@ docker run -p 8080:8080 netwatch
## Rationale ## Rationale
When debugging network issues, it's useful to have a persistent at-a-glance view 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.
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 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:
code lives in `src/main.js` with a class-based architecture:
- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis - **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis ticks, etc.)
ticks, etc.) - **`HostState`**: Per-host state management — history buffer, latency tracking, status transitions
- **`HostState`**: Per-host state management — history buffer, latency tracking, - **`AppState`**: Top-level state container — WAN hosts, local hosts, pause state, aggregate stats
status transitions - **`SparklineRenderer`**: Canvas 2D sparkline drawing with fixed axes, color-coded line segments, error regions, and DPR-aware scaling
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause - **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` / `updateSummary()` / `updateHealthBox()` handle incremental updates
state, aggregate stats - **`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)
- **`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
- **22 WAN hosts**: datavi.be, Anthropic API, OpenAI API, AWS Console, GCP - **9 WAN hosts**: Google Cloud Console, AWS Console, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, datavi.be
Console, Azure, Cloudflare, Fastly, Akamai, GitHub, B2, 7 S3 regional - **1 Local host**: Local Gateway (192.168.100.1), tracked separately from WAN stats
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 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.
`performance.now()`. 1-second timeout; anything over 1000ms is clamped to
unreachable. IPv4 only.
### Color coding ### Color coding
@ -98,40 +72,31 @@ 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 - Play/pause: pause stops probes but history keeps scrolling (blank gaps, no false outage)
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 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.
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, - Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12, 192.168/16)
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 Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom properties.
properties.
## Limitations ## Limitations
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses - **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.
`no-cors` mode which allows the request but provides opaque responses. Latency - **Local gateway**: The 192.168.100.1 endpoint requires the host to be accessible from your network.
is still measurable based on request timing. - **Network conditions**: Measurements reflect browser-to-endpoint latency, which includes your local network, ISP, and internet routing.
- **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,22 +1,23 @@
# Development Policies # Development Policies
- Docker image references by tag are server-mutable, therefore using them is an - Docker image references by tag are server-mutable, therefore using them is
RCE vulnerability. All docker image references must use cryptographic hashes an RCE vulnerability. All docker image references must use cryptographic
to securely specify the exact image that is expected. hashes to securely specify the exact image that is expected.
- Correspondingly, `go install` commands using things like '@latest' are also - Correspondingly, `go install` commands using things like '@latest' are
dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go install also dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go
targets using commit hashes which are cryptographically secure. install targets using commit hashes which are cryptographically secure.
- Every repo with software in it must have a Makefile in the root. Each such - Every repo with software in it must have a Makefile in the root. Each
Makefile should support `make test` (runs the project-specific tests), such Makefile should support `make test` (runs the project-specific
`make lint`, `make fmt` (writes), `make fmt-check` (readonly), and tests), `make lint`, `make fmt` (writes), `make fmt-check` (readonly), and
`make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make docker` `make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make
(builds docker image). docker` (builds docker image).
- Every repo should have a Dockerfile. If the repo contains non-server software, - Every repo should have a Dockerfile. If the repo contains non-server
the Dockerfile should bring up a development environment and `make check` software, the Dockerfile should bring up a development environment and
(i.e. the docker build should fail if the branch is not green). `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, - 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
@ -24,27 +25,27 @@
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` should pre-commit hook. If it is not possible, `make lint && make fmt-check`
be a pre-commit hook. should be a pre-commit hook.
- If a working `make test` takes more than 20 seconds, that's a bug that needs - If a working `make test` takes more than 20 seconds, that's a bug that
fixing. In fact, there should be a timeout specified in the `Makefile` that needs fixing. In fact, there should be a timeout specified in the
fails it automatically if it takes >30s. `Makefile` that 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 feature - Do all changes on a feature branch. You can do whatever you want on a
branch. feature 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 and `docker-compose.yml`, put a comment above the line and show the version
date at which it was current. and date at which it was current.
- For javascript, always use `yarn` over `npm`. - For javascript, always use `yarn` over `npm`.
@ -53,11 +54,11 @@
- 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 unless - Dockerized web services should listen on the default HTTP port of 8080
overridden with the `PORT` environment variable. unless overridden with the `PORT` environment variable.
- The `README.md` is a project's primary documentation. It should contain at a - The `README.md` is a project's primary documentation. It should contain
minimum the following sections: at a 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
@ -67,10 +68,10 @@
- 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 that - eg: "µPaaS is an MIT-licensed Go web application by @sneak
receives git-frontend webhooks and interacts with a Docker server that receives git-frontend webhooks and interacts with a
to build and deploy applications in realtime as certain branches Docker server to build and deploy applications in realtime as
are updated." certain branches 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
@ -78,27 +79,28 @@
- 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, even - This is your TODO list for the project - update it meticulously,
in between commits. Whenever planning, put your todo list in the even in between commits. Whenever planning, put your todo list in
README so that a separate agent with new context can pick up where you the README so that a separate agent with new context can pick up
left off. where you left off.
- License - License
- GPL or MIT or WTFPL - ask the user when beginning a new project and - GPL or MIT or WTFPL - ask the user when beginning a new project
include a LICENSE file in the root and in a section in the README. and include a LICENSE file in the root and in a section in the
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 commit - When beginning a new project, initialize a git repo and make the first
simply the first version of the README.md in the root of the repo. 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 - For Go packages, the module root is `sneak.berlin/go/...`, such
`sneak.berlin/go/dnswatcher`. as `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 `>=1.0.0`, migrations and assume no installed base or existing databases. If
database changes add new migration files. `>=1.0.0`, 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,21 +2,11 @@ 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: 3000, updateInterval: 2000,
historyDuration: 300, historyDuration: 300,
get requestTimeout() { requestTimeout: 1000,
return this.updateInterval - 50; maxLatency: 1000,
},
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,
@ -25,136 +15,29 @@ 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: "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: "Google Cloud Console", url: "https://console.cloud.google.com" }, { name: "Google Cloud Console", url: "https://console.cloud.google.com" },
{ name: "Microsoft Azure", url: "https://portal.azure.com" }, { name: "AWS Console", url: "https://console.aws.amazon.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: "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: "GitHub", url: "https://github.com" }, { name: "datavi.be", url: "https://datavi.be" },
{ 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",
},
]; ];
// The cable modem / CPE upstream of the local gateway — always monitored. const LOCAL_HOSTS = [{ name: "Local Gateway", url: "http://192.168.100.1" }];
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, pinned = false) { 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'
this.pinned = pinned;
} }
pushSample(timestamp, result) { pushSample(timestamp, result) {
@ -190,13 +73,10 @@ class HostState {
} }
class AppState { class AppState {
constructor(localHosts) { constructor() {
this.wan = WAN_HOSTS.map( this.wan = WAN_HOSTS.map((h) => new HostState(h));
(h) => new HostState(h, h.name === "datavi.be"), this.local = LOCAL_HOSTS.map((h) => new HostState(h));
);
this.local = localHosts.map((h) => new HostState(h));
this.paused = false; this.paused = false;
this.tickCount = 0;
} }
get allHosts() { get allHosts() {
@ -221,22 +101,6 @@ 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;
@ -260,7 +124,7 @@ async function measureLatency(url) {
try { try {
await fetch(targetUrl.toString(), { await fetch(targetUrl.toString(), {
method: "GET", method: "HEAD",
mode: "no-cors", mode: "no-cors",
cache: "no-store", cache: "no-store",
signal: controller.signal, signal: controller.signal,
@ -320,11 +184,7 @@ 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) => const getY = (lat) => m.top + ch - (lat / CONFIG.maxLatency) * ch;
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);
@ -336,7 +196,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.graphMaxLatency) * 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.beginPath();
@ -431,18 +291,10 @@ 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">
<button class="pin-btn flex-shrink-0 ${pinColor} transition-colors" data-pin="${index}" title="Pin to top"> <div class="w-48 flex-shrink-0">
<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>
@ -465,7 +317,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="mx-auto px-[10%] py-8"> <div class="max-w-7xl mx-auto px-4 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>
@ -486,22 +338,17 @@ 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">Now:</span> <span class="text-gray-400">Min:</span> <span id="summary-min" class="text-green-400">--ms</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">${CONFIG.historyDuration}s:</span> <span class="text-gray-400">Max:</span> <span id="summary-max" class="text-red-400">--ms</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">Checks:</span> <span id="summary-checks" class="text-gray-300">0</span> <span class="text-gray-400">Avg:</span> <span id="summary-avg" class="text-yellow-400">--ms</span>
</div> </div>
</header> </header>
@ -558,7 +405,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 mt-1 ${latencyClass(avg, "online")}`; 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";
@ -575,14 +422,11 @@ 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}`;
@ -594,35 +438,18 @@ function updateSummary(state) {
: "text-yellow-400"; : "text-yellow-400";
if (stats.min !== null) { if (stats.min !== null) {
minEl.textContent = `${stats.min}`; minEl.textContent = `${stats.min}ms`;
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 {
minEl.textContent = "--"; for (const el of [minEl, maxEl, avgEl]) {
minEl.className = "text-gray-500"; el.textContent = "--ms";
avgEl.textContent = "--"; el.className = "text-gray-500";
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) {
@ -645,43 +472,6 @@ 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) {
@ -711,12 +501,6 @@ 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);
} }
@ -757,40 +541,14 @@ function handleResize(state) {
// --- Bootstrap --------------------------------------------------------------- // --- Bootstrap ---------------------------------------------------------------
async function init() { function init() {
// Probe common gateway IPs to find the local router const state = new AppState();
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);