5 Commits

Author SHA1 Message Date
818accc454 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
2026-02-22 15:59:10 +01:00
ca403e68d1 Pin Dockerfile base images to SHA256 digests
Mutable tags on Docker Hub enable RCE during builds.
Pin to exact digests with version/date comments for auditability.
2026-02-22 14:55:55 +01:00
e106dd5332 Update README with Docker, health indicator, and WAN/local docs 2026-02-22 14:51:55 +01:00
fa3d50393e Add Dockerfile with nginx for static serving
- Multi-stage build: node:22-alpine builds, nginx:stable-alpine serves
- RFC1918 set_real_ip_from for 10/8, 172.16/12, 192.168/16
- X-Forwarded-For real_ip_header with recursive resolution
- Access log to stdout, error log to stderr (Docker best practice)
- Immutable cache headers for hashed static assets
- SPA fallback via try_files
2026-02-22 14:51:33 +01:00
651c86211d Refactor to classes and implement UI improvements
Architecture:
- Extract AppState and HostState classes (no global mutable state)
- Extract SparklineRenderer class with static methods
- Extract CONFIG object for all constants
- Break monolithic functions into focused helpers

Features:
- Clickable service URLs (open in new tab, existing styling)
- Health status box above summary (red DEGRADED if >half unreachable)
- Local Gateway separated into bottom group
- Local Gateway excluded from WAN min/max/avg summary stats
- Pause stops probes but history keeps scrolling (blank gaps, no false outage)
- WAN_HOSTS / LOCAL_HOSTS separation with indexed rendering
2026-02-22 14:51:06 +01:00
14 changed files with 812 additions and 519 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
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
}

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
# nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
# Remove default config
RUN rm /etc/nginx/conf.d/default.conf
# Config template — envsubst replaces $PORT at container start
COPY <<'EOF' /etc/nginx/netwatch.conf.template
server {
listen $PORT;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Trust RFC1918 reverse proxies for X-Forwarded-For
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Access log to stdout (Docker best practice)
access_log /dev/stdout combined;
error_log /dev/stderr warn;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
EOF
COPY --from=build /app/dist /usr/share/nginx/html
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 .

119
README.md
View File

@@ -1,32 +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. ## Getting Started
## Features
- **Real-time monitoring**: Updates every 250ms
- **10 monitored hosts**: Google Cloud, AWS, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, local gateway (192.168.100.1), and datavi.be
- **Visual latency display**: Large color-coded latency figures (NNNms format)
- **Sparkline graphs**: 300-second history visualization for each host
- **Color coding**:
- Green: <50ms (excellent)
- Lime: <100ms (good)
- Yellow: <200ms (moderate)
- Orange: <500ms (poor)
- Red: >500ms (bad)
- Gray: offline/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
- 5-second timeout for unresponsive hosts
- Canvas-based sparkline rendering
- Tailwind CSS v4 for styling
## Build
```bash ```bash
# Install dependencies # Install dependencies
@@ -40,20 +14,48 @@ yarn build
# Preview production build # Preview production build
yarn preview yarn preview
# Docker
docker build -t netwatch .
docker run -p 8080:8080 netwatch
``` ```
## Deployment ## Rationale
After running `yarn build`, deploy the contents of the `dist/` directory to any static file host: 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.
- AWS S3 ## Design
- Google Cloud Storage
- Cloudflare Pages
- Vercel
- Netlify
- GitHub Pages
## Output Structure 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.)
- **`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)
### Monitoring targets
- **9 WAN hosts**: Google Cloud Console, AWS Console, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, datavi.be
- **1 Local host**: Local Gateway (192.168.100.1), tracked separately from WAN stats
### Latency measurement
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with `performance.now()`. 1-second timeout; anything over 1000ms is clamped to unreachable. IPv4 only.
### Color coding
| Latency | Color |
| ----------- | ------ |
| < 50ms | Green |
| < 100ms | Lime |
| < 200ms | Yellow |
| < 500ms | Orange |
| >= 500ms | Red |
| Unreachable | Gray |
### Output structure
``` ```
dist/ dist/
@@ -63,15 +65,32 @@ dist/
└── index-*.js └── index-*.js
``` ```
All assets are bundled and minified. No external dependencies at runtime. ## Features
- Real-time monitoring with 2s update interval and 300s history sparklines
- Health indicator: green (HEALTHY) or red (DEGRADED) based on WAN reachability
- Summary stats: reachable count, min/max/avg latency across WAN hosts only
- Fixed chart axes: Y-axis 01000ms, X-axis 0300s
- Color-coded latency figures and sparkline line segments
- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no false outage)
- Clickable service URLs
- Canvas-based sparkline rendering with devicePixelRatio scaling
- Zero runtime dependencies: all resources bundled into build artifacts
## Deployment
After running `yarn build`, deploy the contents of the `dist/` directory to any static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or use the Docker image behind a reverse proxy.
The Docker image:
- Listens on port 8080 by default (override with `PORT` env var)
- Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12, 192.168/16)
- Sends access logs to stdout
- Caches static assets with immutable headers
## Browser Compatibility ## Browser Compatibility
Requires modern browser with: Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom properties.
- ES modules support
- Fetch API
- Canvas API
- CSS custom properties
## Limitations ## Limitations
@@ -79,6 +98,18 @@ Requires modern browser with:
- **Local gateway**: The 192.168.100.1 endpoint requires the host to be accessible from your network. - **Local gateway**: The 192.168.100.1 endpoint requires the host to be accessible from your network.
- **Network conditions**: Measurements reflect browser-to-endpoint latency, which includes your local network, ISP, and internet routing. - **Network conditions**: Measurements reflect browser-to-endpoint latency, which includes your local network, ISP, and internet routing.
## TODO
- Add unit tests
- Add eslint for JS linting (currently lint target runs prettier only)
- Add configurable host list (environment variable or config file)
- Add latency history export (CSV/JSON)
- Add notification/alert when status changes to DEGRADED
## License ## License
MIT MIT. See [LICENSE](LICENSE).
## Author
[@sneak](https://sneak.berlin)

110
REPO_POLICIES.md Normal file
View File

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

View File

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

View File

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

View File

@@ -1,258 +1,347 @@
import './styles.css' import "./styles.css";
// Configuration // --- Configuration -----------------------------------------------------------
const UPDATE_INTERVAL = 2000 // ms
const HISTORY_DURATION = 300 // seconds
const MAX_HISTORY_POINTS = Math.ceil((HISTORY_DURATION * 1000) / UPDATE_INTERVAL) // 150 points
const REQUEST_TIMEOUT = 1000 // ms
// Pause state const CONFIG = Object.freeze({
let isPaused = false updateInterval: 2000,
let intervalId = null 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);
},
});
// Hosts to monitor (IPv4 preferred endpoints) const WAN_HOSTS = [
const HOSTS = [ { name: "Google Cloud Console", url: "https://console.cloud.google.com" },
{ name: 'Google Cloud Console', url: 'https://console.cloud.google.com', color: '#4285f4' }, { name: "AWS Console", url: "https://console.aws.amazon.com" },
{ name: 'AWS Console', url: 'https://console.aws.amazon.com', color: '#ff9900' }, { name: "GitHub", url: "https://github.com" },
{ name: 'GitHub', url: 'https://github.com', color: '#f0f6fc' }, { name: "Cloudflare", url: "https://www.cloudflare.com" },
{ name: 'Cloudflare', url: 'https://www.cloudflare.com', color: '#f38020' }, { name: "Microsoft Azure", url: "https://portal.azure.com" },
{ name: 'Microsoft Azure', url: 'https://portal.azure.com', color: '#0078d4' }, { name: "DigitalOcean", url: "https://www.digitalocean.com" },
{ name: 'DigitalOcean', url: 'https://www.digitalocean.com', color: '#0080ff' }, { name: "Fastly CDN", url: "https://www.fastly.com" },
{ name: 'Fastly CDN', url: 'https://www.fastly.com', color: '#ff282d' }, { name: "Akamai", url: "https://www.akamai.com" },
{ name: 'Akamai', url: 'https://www.akamai.com', color: '#0096d6' }, { name: "datavi.be", url: "https://datavi.be" },
{ name: 'Local Gateway', url: 'http://192.168.100.1', color: '#a855f7' }, ];
{ name: 'datavi.be', url: 'https://datavi.be', color: '#10b981' },
]
// State: history for each host const LOCAL_HOSTS = [{ name: "Local Gateway", url: "http://192.168.100.1" }];
const hostState = HOSTS.map(host => ({
...host, // --- App State ---------------------------------------------------------------
history: [], // Array of { timestamp, latency } or { timestamp, error }
lastLatency: null, class HostState {
status: 'pending', // 'online', 'offline', 'pending', 'error' 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";
}
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,
);
}
_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,
),
};
}
/** 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 -----------------------------------------------------
// Measure latency using HEAD request
async function measureLatency(url) { async function measureLatency(url) {
const controller = new AbortController() const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT) const timeoutId = setTimeout(
() => controller.abort(),
CONFIG.requestTimeout,
);
// Add cache-busting parameter const targetUrl = new URL(url);
const targetUrl = new URL(url) targetUrl.searchParams.set("_cb", Date.now().toString());
targetUrl.searchParams.set('_cb', Date.now().toString())
const start = performance.now() const start = performance.now();
try { try {
await fetch(targetUrl.toString(), { await fetch(targetUrl.toString(), {
method: 'HEAD', method: "HEAD",
mode: 'no-cors', mode: "no-cors",
cache: 'no-store', cache: "no-store",
signal: controller.signal, signal: controller.signal,
}) });
const latency = Math.round(performance.now() - start);
const latency = Math.round(performance.now() - start) clearTimeout(timeoutId);
clearTimeout(timeoutId) if (latency > CONFIG.maxLatency)
// Clamp >1000ms to unreachable return { latency: null, error: "timeout" };
if (latency > 1000) { return { latency, error: null };
return { latency: null, error: 'timeout' }
}
return { latency, error: null }
} catch (err) { } catch (err) {
clearTimeout(timeoutId) clearTimeout(timeoutId);
if (err.name === "AbortError")
if (err.name === 'AbortError') { return { latency: null, error: "timeout" };
return { latency: null, error: 'timeout' } return { latency: null, error: "unreachable" };
}
return { latency: null, error: 'unreachable' }
} }
} }
// Get latency color (raw hex for canvas) // --- Color Helpers -----------------------------------------------------------
function getLatencyColor(latency) {
if (latency === null) return '#6b7280' // gray function latencyHex(latency) {
if (latency < 50) return '#22c55e' // green if (latency === null) return "#6b7280";
if (latency < 100) return '#84cc16' // lime if (latency < 50) return "#22c55e";
if (latency < 200) return '#eab308' // yellow if (latency < 100) return "#84cc16";
if (latency < 500) return '#f97316' // orange if (latency < 200) return "#eab308";
return '#ef4444' // red if (latency < 500) return "#f97316";
return "#ef4444";
} }
// Get Tailwind class for latency function latencyClass(latency, status) {
function getLatencyClass(latency, status) { if (status === "offline" || status === "error" || latency === null)
if (status === 'offline' || status === 'error' || latency === null) { return "text-gray-500";
return 'text-gray-500' if (latency < 50) return "text-green-500";
} if (latency < 100) return "text-lime-500";
if (latency < 50) return 'text-green-500' if (latency < 200) return "text-yellow-500";
if (latency < 100) return 'text-lime-500' if (latency < 500) return "text-orange-500";
if (latency < 200) return 'text-yellow-500' return "text-red-500";
if (latency < 500) return 'text-orange-500'
return 'text-red-500'
} }
// Fixed axis constants // --- Sparkline Renderer ------------------------------------------------------
const Y_AXIS_MAX = 1000 // ms
const Y_AXIS_TICKS = [0, 250, 500, 750, 1000]
const X_AXIS_TICKS = [0, 60, 120, 180, 240, 300] // seconds ago
// Draw sparkline on canvas - latest point at right edge, growing left class SparklineRenderer {
function drawSparkline(canvas, history) { static MARGIN = { left: 35, right: 10, top: 5, bottom: 18 };
const ctx = canvas.getContext('2d')
const dpr = window.devicePixelRatio || 1
const width = canvas.width / dpr
const height = canvas.height / dpr
// Margins for axis labels static draw(canvas, history) {
const marginLeft = 35 const ctx = canvas.getContext("2d");
const marginRight = 10 const dpr = window.devicePixelRatio || 1;
const marginTop = 5 const w = canvas.width / dpr;
const marginBottom = 18 const h = canvas.height / dpr;
const m = SparklineRenderer.MARGIN;
const cw = w - m.left - m.right;
const ch = h - m.top - m.bottom;
const chartWidth = width - marginLeft - marginRight ctx.clearRect(0, 0, w, h);
const chartHeight = height - marginTop - marginBottom SparklineRenderer._drawYAxis(ctx, w, h, m, ch);
SparklineRenderer._drawXAxis(ctx, w, h, m, cw);
// Clear canvas const len = history.length;
ctx.clearRect(0, 0, width, height) 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;
// Draw Y-axis labels and grid lines SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch);
ctx.font = '9px monospace' SparklineRenderer._drawLine(ctx, history, getX, getY);
ctx.textAlign = 'right' SparklineRenderer._drawTip(ctx, history, getX, getY);
ctx.textBaseline = 'middle'
Y_AXIS_TICKS.forEach(tick => {
const y = marginTop + chartHeight - (tick / Y_AXIS_MAX) * chartHeight
// Grid line
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(marginLeft, y)
ctx.lineTo(width - marginRight, y)
ctx.stroke()
// Label
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fillText(`${tick}`, marginLeft - 4, y)
})
// Draw X-axis labels
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
X_AXIS_TICKS.forEach(tick => {
// tick is seconds ago, 0 = now (right edge), 300 = 5min ago (left edge)
const x = marginLeft + chartWidth - (tick / HISTORY_DURATION) * chartWidth
// Label
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fillText(`-${tick}s`, x, height - marginBottom + 4)
})
// Helper functions for chart coordinates
const historyLen = history.length
const pointWidth = chartWidth / (MAX_HISTORY_POINTS - 1)
const getX = (i) => marginLeft + chartWidth - (historyLen - 1 - i) * pointWidth
const getY = (latency) => marginTop + chartHeight - (latency / Y_AXIS_MAX) * chartHeight
// Draw error regions first (behind the line)
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'
let inErrorRegion = false
let errorStart = 0
history.forEach((point, i) => {
const x = getX(i)
if (point.latency === null && !inErrorRegion) {
inErrorRegion = true
errorStart = x
} else if (point.latency !== null && inErrorRegion) {
inErrorRegion = false
ctx.fillRect(errorStart, marginTop, x - errorStart, chartHeight)
}
})
if (inErrorRegion) {
const lastX = getX(historyLen - 1)
ctx.fillRect(errorStart, marginTop, lastX - errorStart + 5, chartHeight)
} }
// Draw the sparkline segments - color coded by latency static _drawYAxis(ctx, w, h, m, ch) {
ctx.lineWidth = 2 ctx.font = "9px monospace";
ctx.lineCap = 'round' ctx.textAlign = "right";
ctx.lineJoin = 'round' ctx.textBaseline = "middle";
for (const tick of CONFIG.yAxisTicks) {
let prevPoint = null const y = m.top + ch - (tick / CONFIG.maxLatency) * ch;
let prevX = 0 ctx.strokeStyle = "rgba(255,255,255,0.1)";
let prevY = 0 ctx.lineWidth = 1;
ctx.beginPath();
history.forEach((point, i) => { ctx.moveTo(m.left, y);
if (point.latency === null) { ctx.lineTo(w - m.right, y);
prevPoint = null ctx.stroke();
return ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.fillText(`${tick}`, m.left - 4, y);
}
} }
const x = getX(i) static _drawXAxis(ctx, w, h, m, cw) {
const y = getY(point.latency) ctx.textAlign = "center";
ctx.textBaseline = "top";
if (prevPoint !== null) { for (const tick of CONFIG.xAxisTicks) {
ctx.strokeStyle = getLatencyColor(point.latency) const x = m.left + cw - (tick / CONFIG.historyDuration) * cw;
ctx.beginPath() ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.moveTo(prevX, prevY) ctx.fillText(`-${tick}s`, x, h - m.bottom + 4);
ctx.lineTo(x, y) }
ctx.stroke()
} }
prevPoint = point static _drawErrors(ctx, history, getX, top, ch) {
prevX = x ctx.fillStyle = "rgba(239, 68, 68, 0.2)";
prevY = y 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);
}
// Draw latest point indicator static _drawLine(ctx, history, getX, getY) {
const lastValidPoint = [...history].reverse().find(p => p.latency !== null) ctx.lineWidth = 2;
if (lastValidPoint) { ctx.lineCap = "round";
const lastIndex = history.lastIndexOf(lastValidPoint) ctx.lineJoin = "round";
const x = getX(lastIndex) let prev = null;
const y = getY(lastValidPoint.latency) 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 };
}
}
ctx.fillStyle = getLatencyColor(lastValidPoint.latency) static _drawTip(ctx, history, getX, getY) {
ctx.beginPath() for (let i = history.length - 1; i >= 0; i--) {
ctx.arc(x, y, 3, 0, Math.PI * 2) if (history[i].latency !== null) {
ctx.fill() 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);
} }
} }
// Create the UI // --- UI Renderer -------------------------------------------------------------
function createUI() {
const app = document.getElementById('app')
function hostRowHTML(host, index) {
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">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: ${latencyHex(null)}"></div>
<span class="font-medium text-white truncate">${host.name}</span>
</div>
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 mt-1 truncate block">${host.url}</a>
</div>
<div class="w-32 flex-shrink-0 text-right">
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}">
<span class="text-gray-500">---</span>
</div>
<div class="status-text text-xs text-gray-500 mt-1" data-host="${index}">waiting...</div>
</div>
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
</div>
</div>
</div>`;
}
function buildUI(state) {
const app = document.getElementById("app");
app.innerHTML = ` app.innerHTML = `
<div class="max-w-7xl mx-auto px-4 py-8"> <div class="max-w-7xl mx-auto px-4 py-8">
<header class="mb-8"> <header class="mb-8">
<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>
<button <button id="pause-btn"
id="pause-btn" class="flex items-center gap-3 px-6 py-3 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500">
class="flex items-center gap-3 px-6 py-3 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg id="pause-icon" class="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> <svg id="pause-icon" class="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" /> <rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg> </svg>
<svg id="play-icon" class="w-8 h-8 text-green-400 hidden" fill="currentColor" viewBox="0 0 24 24"> <svg id="play-icon" class="w-8 h-8 text-green-400 hidden" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z"/>
</svg> </svg>
<span id="pause-text" class="text-lg font-semibold text-white">Pause</span> <span id="pause-text" class="text-lg font-semibold text-white">Pause</span>
</button> </button>
</div> </div>
<p class="text-gray-400 text-sm"> <p class="text-gray-400 text-sm">
Real-time network latency monitor | Real-time network latency monitor |
<span class="text-gray-500">Updates every ${UPDATE_INTERVAL / 1000}s</span> | <span class="text-gray-500">Updates every ${CONFIG.updateInterval / 1000}s</span> |
<span class="text-gray-500">History: ${HISTORY_DURATION}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>
<div id="summary" class="mt-4 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm"> <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>
</div>
<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">Min:</span> <span id="summary-min" class="text-green-400">--ms</span>
@@ -263,40 +352,13 @@ function createUI() {
</div> </div>
</header> </header>
<div id="hosts-container" class="space-y-4"> <div id="wan-hosts" class="space-y-4">
${hostState.map((host, index) => ` ${state.wan.map((h, i) => hostRowHTML(h, i)).join("")}
<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">
<!-- Host Info -->
<div class="w-48 flex-shrink-0">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: ${host.color}"></div>
<span class="font-medium text-white truncate">${host.name}</span>
</div>
<div class="text-xs text-gray-500 mt-1 truncate">${host.url}</div>
</div> </div>
<!-- Latency Display --> <h2 class="text-gray-500 text-xs uppercase tracking-wide mt-6 mb-3">Local Network</h2>
<div class="w-32 flex-shrink-0 text-right"> <div id="local-hosts" class="space-y-4">
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}"> ${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i)).join("")}
<span class="text-gray-500">---</span>
</div>
<div class="status-text text-xs text-gray-500 mt-1" data-host="${index}">
waiting...
</div>
</div>
<!-- Sparkline Graph -->
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
<canvas
class="sparkline-canvas w-full"
data-host="${index}"
height="80"
></canvas>
</div>
</div>
</div>
`).join('')}
</div> </div>
<footer class="mt-8 text-center text-gray-600 text-xs"> <footer class="mt-8 text-center text-gray-600 text-xs">
@@ -310,219 +372,192 @@ function createUI() {
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline <span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p> </p>
</footer> </footer>
</div> </div>`;
`
// Set up canvas sizes after DOM is ready
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.querySelectorAll('.sparkline-canvas').forEach(canvas => { document
const rect = canvas.getBoundingClientRect() .querySelectorAll(".sparkline-canvas")
canvas.width = rect.width * window.devicePixelRatio .forEach((c) => SparklineRenderer.sizeCanvas(c));
canvas.height = 80 * window.devicePixelRatio });
canvas.getContext('2d').scale(window.devicePixelRatio, window.devicePixelRatio)
})
})
} }
// Update single host display // --- UI Updaters -------------------------------------------------------------
function updateHostDisplay(index) {
const host = hostState[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 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;
// Update latency value
if (host.lastLatency !== null) { if (host.lastLatency !== null) {
const colorClass = getLatencyClass(host.lastLatency, host.status) const cls = latencyClass(host.lastLatency, host.status);
latencyEl.innerHTML = `<span class="${colorClass}">${host.lastLatency}<span class="text-lg">ms</span></span>` latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
} else if (host.status === 'offline' || host.status === 'error') { } else if (host.status === "offline" || host.status === "error") {
latencyEl.innerHTML = `<span class="text-gray-500">---</span>` latencyEl.innerHTML = `<span class="text-gray-500">---</span>`;
} }
// Update status text const avg = host.averageLatency();
const avgLatency = calculateAverageLatency(host.history) if (host.status === "online" && avg !== null) {
if (host.status === 'online' && avgLatency !== null) { statusEl.textContent = `avg: ${avg}ms`;
statusEl.textContent = `avg: ${avgLatency}ms` statusEl.className = "status-text text-xs text-gray-400 mt-1";
statusEl.className = 'status-text text-xs text-gray-400 mt-1' } else if (host.status === "offline") {
} else if (host.status === 'offline') { statusEl.textContent = "unreachable";
statusEl.textContent = 'unreachable' statusEl.className = "status-text text-xs text-red-400 mt-1";
statusEl.className = 'status-text text-xs text-red-400 mt-1' } else if (host.status === "error") {
} else if (host.status === 'error') { statusEl.textContent = "timeout";
statusEl.textContent = 'timeout' statusEl.className = "status-text text-xs text-orange-400 mt-1";
statusEl.className = 'status-text text-xs text-orange-400 mt-1'
} else { } else {
statusEl.textContent = 'connecting...' statusEl.textContent = "connecting...";
statusEl.className = 'status-text text-xs text-gray-500 mt-1' statusEl.className = "status-text text-xs text-gray-500 mt-1";
} }
// Update sparkline SparklineRenderer.draw(canvas, host.history);
const canvasRect = canvas.getBoundingClientRect()
drawSparkline(canvas, host.history)
} }
// Calculate average latency from history function updateSummary(state) {
function calculateAverageLatency(history) { const stats = state.wanStats();
const validPoints = history.filter(p => p.latency !== null)
if (validPoints.length === 0) return null
const sum = validPoints.reduce((acc, p) => acc + p.latency, 0) const reachableEl = document.getElementById("summary-reachable");
return Math.round(sum / validPoints.length) const minEl = document.getElementById("summary-min");
} const maxEl = document.getElementById("summary-max");
const avgEl = document.getElementById("summary-avg");
if (!reachableEl) return;
// Update summary line with current host stats reachableEl.textContent = `${stats.reachable}/${stats.total}`;
function updateSummary() { reachableEl.className =
const reachableHosts = hostState.filter(h => h.lastLatency !== null) stats.reachable === stats.total
const reachableCount = reachableHosts.length ? "text-green-400"
const totalCount = hostState.length : stats.reachable === 0
? "text-red-400"
: "text-yellow-400";
const reachableEl = document.getElementById('summary-reachable') if (stats.min !== null) {
const minEl = document.getElementById('summary-min') minEl.textContent = `${stats.min}ms`;
const maxEl = document.getElementById('summary-max') minEl.className = latencyClass(stats.min, "online");
const avgEl = document.getElementById('summary-avg') maxEl.textContent = `${stats.max}ms`;
maxEl.className = latencyClass(stats.max, "online");
if (!reachableEl) return avgEl.textContent = `${stats.avg}ms`;
avgEl.className = latencyClass(stats.avg, "online");
reachableEl.textContent = `${reachableCount}/${totalCount}`
reachableEl.className = reachableCount === totalCount ? 'text-green-400' :
reachableCount === 0 ? 'text-red-400' : 'text-yellow-400'
if (reachableCount > 0) {
const latencies = reachableHosts.map(h => h.lastLatency)
const min = Math.min(...latencies)
const max = Math.max(...latencies)
const avg = Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length)
minEl.textContent = `${min}ms`
minEl.className = getLatencyClass(min, 'online')
maxEl.textContent = `${max}ms`
maxEl.className = getLatencyClass(max, 'online')
avgEl.textContent = `${avg}ms`
avgEl.className = getLatencyClass(avg, 'online')
} else { } else {
minEl.textContent = '--ms' for (const el of [minEl, maxEl, avgEl]) {
minEl.className = 'text-gray-500' el.textContent = "--ms";
maxEl.textContent = '--ms' el.className = "text-gray-500";
maxEl.className = 'text-gray-500' }
avgEl.textContent = '--ms'
avgEl.className = 'text-gray-500'
} }
} }
// Main measurement loop function updateHealthBox(state) {
async function measureAll() { const el = document.getElementById("health-text");
const timestamp = Date.now() const box = document.getElementById("health-box");
if (!el || !box) 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"
}`;
}
// --- Main Loop ---------------------------------------------------------------
async function tick(state) {
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);
}
// 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;
}
// Measure all hosts in parallel
const results = await Promise.all( const results = await Promise.all(
hostState.map(host => measureLatency(host.url)) state.allHosts.map((h) => measureLatency(h.url)),
) );
// Update state state.allHosts.forEach((host, i) => {
results.forEach((result, index) => { host.pushSample(ts, results[i]);
const host = hostState[index] updateHostRow(host, i);
});
// Add to history updateSummary(state);
host.history.push({ updateHealthBox(state);
timestamp, }
latency: result.latency,
error: result.error,
})
// Trim history to max size // --- Pause / Resume ----------------------------------------------------------
while (host.history.length > MAX_HISTORY_POINTS) {
host.history.shift()
}
// Update current state function togglePause(state) {
host.lastLatency = result.latency state.paused = !state.paused;
if (result.error === 'timeout') { const pauseIcon = document.getElementById("pause-icon");
host.status = 'error' const playIcon = document.getElementById("play-icon");
} else if (result.error) { const pauseText = document.getElementById("pause-text");
host.status = 'offline' 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 { } else {
host.status = 'online' pauseIcon.classList.remove("hidden");
} playIcon.classList.add("hidden");
pauseText.textContent = "Pause";
// Update display indicator.textContent = "Running";
updateHostDisplay(index) indicator.className = "text-green-400";
})
// Update summary
updateSummary()
}
// Handle window resize
function handleResize() {
document.querySelectorAll('.sparkline-canvas').forEach((canvas, index) => {
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width * window.devicePixelRatio
canvas.height = 80 * window.devicePixelRatio
const ctx = canvas.getContext('2d')
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
// Redraw
if (hostState[index]) {
drawSparkline(canvas, hostState[index].history)
}
})
}
// Toggle pause state
function togglePause() {
isPaused = !isPaused
const pauseIcon = document.getElementById('pause-icon')
const playIcon = document.getElementById('play-icon')
const pauseText = document.getElementById('pause-text')
const statusIndicator = document.getElementById('status-indicator')
if (isPaused) {
// Stop the interval
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
pauseIcon.classList.add('hidden')
playIcon.classList.remove('hidden')
pauseText.textContent = 'Resume'
statusIndicator.textContent = 'Paused'
statusIndicator.className = 'text-yellow-400'
} else {
// Resume the interval
measureAll()
intervalId = setInterval(measureAll, UPDATE_INTERVAL)
pauseIcon.classList.remove('hidden')
playIcon.classList.add('hidden')
pauseText.textContent = 'Pause'
statusIndicator.textContent = 'Running'
statusIndicator.className = 'text-green-400'
} }
} }
// Initialize // --- 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);
});
}
// --- Bootstrap ---------------------------------------------------------------
function init() { function init() {
createUI() const state = new AppState();
buildUI(state);
// Wire up pause button document
document.getElementById('pause-btn').addEventListener('click', togglePause) .getElementById("pause-btn")
.addEventListener("click", () => togglePause(state));
// Start measurement loop tick(state);
measureAll() setInterval(() => tick(state), CONFIG.updateInterval);
intervalId = setInterval(measureAll, UPDATE_INTERVAL)
// Handle resize window.addEventListener("resize", () => handleResize(state));
window.addEventListener('resize', handleResize) setTimeout(() => handleResize(state), 100);
// Initial resize setup after a short delay
setTimeout(handleResize, 100)
} }
// Run when DOM is ready if (document.readyState === "loading") {
if (document.readyState === 'loading') { document.addEventListener("DOMContentLoaded", init);
document.addEventListener('DOMContentLoaded', init)
} else { } else {
init() init();
} }

View File

@@ -10,9 +10,14 @@
} }
body { body {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
} }
.sparkline-container { .sparkline-container {
background: linear-gradient(to bottom, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0) 100%); background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.02) 0%,
rgba(255, 255, 255, 0) 100%
);
} }

View File

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

View File

@@ -668,6 +668,11 @@ postcss@^8.5.6:
picocolors "^1.1.1" picocolors "^1.1.1"
source-map-js "^1.2.1" source-map-js "^1.2.1"
prettier@^3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
rollup@^4.43.0: rollup@^4.43.0:
version "4.57.0" version "4.57.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"