import "./styles.css"; // --- 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). // reportInterval is how often collected samples are POSTed to the backend. const CONFIG = { updateInterval: 3000, maxHistoryPoints: 100, reportInterval: 60000, get historyDuration() { return (this.maxHistoryPoints * this.updateInterval) / 1000; }, get requestTimeout() { return Math.min(this.updateInterval - 100, 3000); }, get maxLatency() { return this.requestTimeout; }, graphMaxLatency: 1000, yAxisTicks: [0, 250, 500, 750, 1000], get xAxisTicks() { const d = this.historyDuration; return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5)); }, canvasHeight: 96, }; // 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 = [ { 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: "Microsoft Azure", url: "https://portal.azure.com" }, { name: "Cloudflare", url: "https://www.cloudflare.com" }, { name: "Fastly CDN", url: "https://www.fastly.com" }, { name: "Akamai", url: "https://www.akamai.com" }, { name: "Google", url: "https://www.google.com" }, { 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-1 (Singapore)", url: "https://s3.ap-southeast-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", }, ]; // --- Debug Log --------------------------------------------------------------- const debugLog = []; const log = (() => { function append(level, message) { debugLog.push({ timestamp: new Date(), level, message }); if (debugLog.length > 1000) debugLog.splice(0, debugLog.length - 1000); const panel = document.getElementById("debug-panel"); if (panel && !panel.classList.contains("hidden")) renderDebugLog(); } return { error: (msg) => append("error", msg), warning: (msg) => append("warning", msg), notice: (msg) => append("notice", msg), info: (msg) => append("info", msg), debug: (msg) => append("debug", msg), }; })(); // 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", ]; // --- Formatting Helpers ------------------------------------------------------ function formatUTCTimestamp(date) { const p = (n) => String(n).padStart(2, "0"); return `[${date.getUTCFullYear()}-${p(date.getUTCMonth() + 1)}-${p(date.getUTCDate())} ${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}]`; } // --- Duration Formatting ----------------------------------------------------- function humanDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; let result = ""; if (h) result += `${h}h`; if (m) result += `${m}m`; if (s || !result) result += `${s}s`; return result; } // --- 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 { log.error("All gateway candidates failed"); return null; } } // --- App State --------------------------------------------------------------- class HostState { constructor(host, pinned = false) { this.name = host.name; this.url = host.url; this.history = []; // { timestamp, latency, paused } this.lastLatency = null; this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending' this.pinned = pinned; } 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, ); } minLatency() { const valid = this.history.filter((p) => p.latency !== null); if (valid.length === 0) return null; return Math.min(...valid.map((p) => p.latency)); } maxLatency() { const valid = this.history.filter((p) => p.latency !== null); if (valid.length === 0) return null; return Math.max(...valid.map((p) => p.latency)); } medianLatency() { const sorted = this.history .filter((p) => p.latency !== null) .map((p) => p.latency) .sort((a, b) => a - b); if (sorted.length === 0) return null; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2); } _trim() { while (this.history.length > CONFIG.maxHistoryPoints) this.history.shift(); } } class AppState { constructor(localHosts) { this.wan = WAN_HOSTS.map( (h) => new HostState(h, h.name === "datavi.be"), ); this.local = localHosts.map((h) => new HostState(h)); this.paused = false; this.tickCount = 0; } 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, med: null, avg: null, }; const sorted = [...latencies].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); const med = sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2); return { reachable: latencies.length, total, min: Math.min(...latencies), max: Math.max(...latencies), med, avg: Math.round( latencies.reduce((a, b) => a + b, 0) / latencies.length, ), }; } /** Min/max across all WAN host history (the full history 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: "healthy" | "slow" | "degraded" | "offline" */ healthStatus() { const reachable = this.wan.filter((h) => h.status === "online").length; const timeouts = this.wan.filter( (h) => h.status === "error" || h.status === "offline", ).length; if (timeouts > 10 && reachable <= 4) return "offline"; if (timeouts > 4) return "degraded"; const slow = this.wan.filter( (h) => h.lastLatency !== null && h.lastLatency > 1000, ).length; if (slow > 3) return "slow"; return "healthy"; } /** True when every WAN host is timing out — total connectivity loss */ isHardOffline() { return this.wan.every( (h) => h.status === "error" || h.status === "offline", ); } } // --- Reporting --------------------------------------------------------------- // A random id identifying this browser across reports. Generated once and // kept in localStorage; if storage is unavailable (e.g. private mode) a // fresh id is used for this session only. function getClientId() { const key = "netwatch-client-id"; try { let id = localStorage.getItem(key); if (!id) { id = crypto.randomUUID(); localStorage.setItem(key, id); } return id; } catch { return crypto.randomUUID(); } } // Build the delta report body the backend decodes, plus the new per-host // high-water marks. Pure function of the passed state: `hosts` is an array // of { name, url, status, history }, `since` maps a host url to the Unix-ms // timestamp of the last sample already reported for it, and `now` is a Date. // Only non-paused samples newer than the mark are included. Returns null // when no host has an unreported sample. function buildReport(hosts, clientId, now, since) { const reportHosts = []; const marks = new Map(); for (const host of hosts) { const mark = since.get(host.url) ?? 0; const samples = []; let high = mark; for (const p of host.history) { if (p.paused) continue; if (p.timestamp <= mark) continue; samples.push({ t: p.timestamp, latency: p.latency, error: p.error ?? null, }); if (p.timestamp > high) high = p.timestamp; } if (samples.length === 0) continue; reportHosts.push({ name: host.name, url: host.url, status: host.status, history: samples, }); marks.set(host.url, high); } if (reportHosts.length === 0) return null; return { body: { clientId, geo: null, hosts: reportHosts, timestamp: now.toISOString(), }, marks, }; } // Periodically POSTs unreported samples to the same-origin backend. Holds // the per-host high-water marks so each report is a delta; marks only // advance on a delivered report, so a failed POST simply re-sends those // samples next interval (bounded by the history window — whatever has since // fallen out is dropped). Failure is quiet: one debug line per outage, one // on recovery, never an alert, never a tight retry loop. class Reporter { constructor(state, clientId, intervalMs) { this.state = state; this.clientId = clientId; this.intervalMs = intervalMs; this.marks = new Map(); this.failing = false; this.timerId = null; } start() { if (this.timerId) return; this.timerId = setInterval(() => this.flush(), this.intervalMs); } async flush() { if (this.state.paused) return; const report = buildReport( this.state.allHosts, this.clientId, new Date(), this.marks, ); if (!report) return; try { const resp = await fetch("/api/v1/reports", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "omit", body: JSON.stringify(report.body), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); for (const [url, t] of report.marks) this.marks.set(url, t); if (this.failing) { log.debug("Report delivery recovered"); this.failing = false; } } catch (err) { if (!this.failing) { log.debug(`Report delivery failed: ${err.message}`); this.failing = true; } } } } // --- Latency Measurement ----------------------------------------------------- async function measureLatency(url) { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), CONFIG.requestTimeout, ); const targetUrl = new URL(url); targetUrl.searchParams.set("_cb", Date.now().toString()); const start = performance.now(); try { await fetch(targetUrl.toString(), { method: "GET", mode: "no-cors", cache: "no-store", signal: controller.signal, }); const latency = Math.round(performance.now() - start); clearTimeout(timeoutId); if (latency > CONFIG.maxLatency) { log.error(`${url} timeout (${latency}ms > ${CONFIG.maxLatency}ms)`); return { latency: null, error: "timeout" }; } return { latency, error: null }; } catch (err) { clearTimeout(timeoutId); if (err.name === "AbortError") { log.error(`${url} timeout (aborted)`); return { latency: null, error: "timeout" }; } log.error(`${url} unreachable`); return { latency: null, error: "unreachable" }; } } // --- Color Helpers ----------------------------------------------------------- function latencyHex(latency) { if (latency === null) return "#6b7280"; if (latency < 50) return "#22c55e"; if (latency < 100) return "#84cc16"; if (latency < 200) return "#eab308"; if (latency < 500) return "#f97316"; return "#ef4444"; } function latencyClass(latency, status) { if (status === "offline" || status === "error" || latency === null) return "text-gray-500"; if (latency < 50) return "text-green-500"; if (latency < 100) return "text-lime-500"; if (latency < 200) return "text-yellow-500"; if (latency < 500) return "text-orange-500"; return "text-red-500"; } // --- Sparkline Renderer ------------------------------------------------------ class SparklineRenderer { static MARGIN = { left: 42, right: 10, top: 5, bottom: 22 }; static draw(canvas, history) { const ctx = canvas.getContext("2d"); const dpr = window.devicePixelRatio || 1; const w = canvas.width / dpr; const h = canvas.height / dpr; const m = SparklineRenderer.MARGIN; const cw = w - m.left - m.right; const ch = h - m.top - m.bottom; ctx.clearRect(0, 0, w, h); SparklineRenderer._drawYAxis(ctx, w, h, m, ch); SparklineRenderer._drawXAxis(ctx, w, h, m, cw); const len = history.length; const pw = cw / (CONFIG.maxHistoryPoints - 1); const getX = (i) => m.left + cw - (len - 1 - i) * pw; const getY = (lat) => m.top + ch - (Math.min(lat, CONFIG.graphMaxLatency) / CONFIG.graphMaxLatency) * ch; SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch); SparklineRenderer._drawLine(ctx, history, getX, getY); SparklineRenderer._drawTip(ctx, history, getX, getY); } static _drawYAxis(ctx, w, h, m, ch) { ctx.font = "300 12px monospace"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (const tick of CONFIG.yAxisTicks) { const y = m.top + ch - (tick / CONFIG.graphMaxLatency) * ch; ctx.strokeStyle = "rgba(255,255,255,0.1)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(m.left, y); ctx.lineTo(w - m.right, y); ctx.stroke(); ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillText(`${tick}`, m.left - 4, y); } } static _drawXAxis(ctx, w, h, m, cw) { ctx.textAlign = "center"; ctx.textBaseline = "top"; for (const tick of CONFIG.xAxisTicks) { const x = m.left + cw - (tick / CONFIG.historyDuration) * cw; ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillText(`-${humanDuration(tick)}`, x, h - m.bottom + 4); } } static _drawErrors(ctx, history, getX, top, ch) { ctx.fillStyle = "rgba(239, 68, 68, 0.2)"; let inErr = false, start = 0; for (let i = 0; i < history.length; i++) { const p = history[i]; const x = getX(i); // Only real errors, not paused gaps const isError = p.latency === null && !p.paused; if (isError && !inErr) { inErr = true; start = x; } else if (!isError && inErr) { inErr = false; ctx.fillRect(start, top, x - start, ch); } } if (inErr) ctx.fillRect(start, top, getX(history.length - 1) - start + 5, ch); } static _drawLine(ctx, history, getX, getY) { ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.lineJoin = "round"; let prev = null; for (let i = 0; i < history.length; i++) { const p = history[i]; if (p.latency === null) { prev = null; continue; } const x = getX(i), y = getY(p.latency); if (prev) { ctx.strokeStyle = latencyHex(p.latency); ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(x, y); ctx.stroke(); } prev = { x, y }; } } static _drawTip(ctx, history, getX, getY) { for (let i = history.length - 1; i >= 0; i--) { if (history[i].latency !== null) { const x = getX(i), y = getY(history[i].latency); ctx.fillStyle = latencyHex(history[i].latency); ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); return; } } } static sizeCanvas(canvas) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = CONFIG.canvasHeight * dpr; const ctx = canvas.getContext("2d"); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); } } // --- UI Renderer ------------------------------------------------------------- // The per-host status line must stay wrappable: its populated content is // wider than the host column at a 320px viewport, and `whitespace-nowrap` // here overflows the element and forces the whole document to scroll // horizontally. const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5"; function hostRowHTML(host, index, showPin = true) { const pinColor = host.pinned ? "text-blue-500" : "text-gray-600 hover:text-gray-400"; const pinRotate = host.pinned ? "" : "rotate-45"; const pinBtn = showPin ? `` : `
`; return `Real-time network latency monitor | Updates every ${humanDuration(CONFIG.updateInterval / 1000)} | History: ${humanDuration(CONFIG.historyDuration)} | Running
|