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). const CONFIG = { updateInterval: 3000, maxHistoryPoints: 100, 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", ); } } // --- 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 ------------------------------------------------------------- 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 `
${pinBtn}
${host.name}
---
${host.url}
waiting...
`; } function wanRowsHTML(wanHosts) { const pinnedCount = wanHosts.filter((h) => h.pinned).length; const rows = wanHosts.map((h, i) => hostRowHTML(h, i)); if (pinnedCount > 0 && pinnedCount < wanHosts.length) { rows.splice( pinnedCount, 0, `

`, ); } return rows.join(""); } function buildUI(state) { const app = document.getElementById("app"); app.innerHTML = `

NetWatch by @sneak

Real-time network latency monitor | Updates every ${humanDuration(CONFIG.updateInterval / 1000)} | History: ${humanDuration(CONFIG.historyDuration)} | Running

|

Waiting for data...
Reachable: --/-- | Now: min --ms / med --ms / avg --ms / max --ms | ${humanDuration(CONFIG.historyDuration)}: min --ms / max --ms | Checks: 0
${wanRowsHTML(state.wan)}

Local Network

${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i, false)).join("")}
`; requestAnimationFrame(() => { document .querySelectorAll(".sparkline-canvas") .forEach((c) => SparklineRenderer.sizeCanvas(c)); }); } // --- UI Updaters ------------------------------------------------------------- function updateHostRow(host, index) { const latencyEl = document.querySelector( `.latency-value[data-host="${index}"]`, ); const statusEl = document.querySelector( `.status-text[data-host="${index}"]`, ); const canvas = document.querySelector( `.sparkline-canvas[data-host="${index}"]`, ); if (!latencyEl || !statusEl || !canvas) return; if (host.lastLatency !== null) { const cls = latencyClass(host.lastLatency, host.status); latencyEl.innerHTML = `${host.lastLatency}ms`; } else if (host.status === "offline" || host.status === "error") { latencyEl.innerHTML = `---`; } const avg = host.averageLatency(); const med = host.medianLatency(); const min = host.minLatency(); const max = host.maxLatency(); if (host.status === "online" && avg !== null) { statusEl.innerHTML = `min ${min}ms` + ` / ` + `med ${med}ms` + ` / ` + `avg ${avg}ms` + ` / ` + `max ${max}ms`; statusEl.className = "status-text text-xs whitespace-nowrap text-right col-span-2 mt-5"; } else if (host.status === "offline") { statusEl.textContent = "unreachable"; statusEl.className = "status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5"; } else if (host.status === "error") { statusEl.textContent = "timeout"; statusEl.className = "status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5"; } else { statusEl.textContent = "connecting..."; statusEl.className = "status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5"; } SparklineRenderer.draw(canvas, host.history); } function updateSummary(state) { const stats = state.wanStats(); const hstats = state.wanHistoryStats(); const reachableEl = document.getElementById("summary-reachable"); const minEl = document.getElementById("summary-min"); const minUnitEl = document.getElementById("summary-min-unit"); const medEl = document.getElementById("summary-med"); const medUnitEl = document.getElementById("summary-med-unit"); const avgEl = document.getElementById("summary-avg"); const avgUnitEl = document.getElementById("summary-avg-unit"); const maxEl = document.getElementById("summary-max"); const maxUnitEl = document.getElementById("summary-max-unit"); const hminEl = document.getElementById("summary-hmin"); const hminUnitEl = document.getElementById("summary-hmin-unit"); const hmaxEl = document.getElementById("summary-hmax"); const hmaxUnitEl = document.getElementById("summary-hmax-unit"); if (!reachableEl) return; reachableEl.textContent = `${stats.reachable}/${stats.total}`; reachableEl.className = stats.reachable === stats.total ? "text-green-400" : stats.reachable === 0 ? "text-red-400" : "text-yellow-400"; if (stats.min !== null) { minEl.textContent = `${stats.min}`; minEl.className = latencyClass(stats.min, "online"); minUnitEl.className = latencyClass(stats.min, "online"); medEl.textContent = `${stats.med}`; medEl.className = latencyClass(stats.med, "online"); medUnitEl.className = latencyClass(stats.med, "online"); avgEl.textContent = `${stats.avg}`; avgEl.className = latencyClass(stats.avg, "online"); avgUnitEl.className = latencyClass(stats.avg, "online"); maxEl.textContent = `${stats.max}`; maxEl.className = latencyClass(stats.max, "online"); maxUnitEl.className = latencyClass(stats.max, "online"); } else { minEl.textContent = "--"; minEl.className = "text-gray-500"; minUnitEl.className = "text-gray-500"; medEl.textContent = "--"; medEl.className = "text-gray-500"; medUnitEl.className = "text-gray-500"; avgEl.textContent = "--"; avgEl.className = "text-gray-500"; avgUnitEl.className = "text-gray-500"; maxEl.textContent = "--"; maxEl.className = "text-gray-500"; maxUnitEl.className = "text-gray-500"; } if (hstats.min !== null) { hminEl.textContent = `${hstats.min}`; hminEl.className = latencyClass(hstats.min, "online"); hminUnitEl.className = latencyClass(hstats.min, "online"); hmaxEl.textContent = `${hstats.max}`; hmaxEl.className = latencyClass(hstats.max, "online"); hmaxUnitEl.className = latencyClass(hstats.max, "online"); } else { hminEl.textContent = "--"; hminEl.className = "text-gray-500"; hminUnitEl.className = "text-gray-500"; hmaxEl.textContent = "--"; hmaxEl.className = "text-gray-500"; hmaxUnitEl.className = "text-gray-500"; } const checksEl = document.getElementById("summary-checks"); if (checksEl) checksEl.textContent = state.tickCount; } function updateHealthBox(state) { const el = document.getElementById("health-text"); const box = document.getElementById("health-box"); if (!el || !box) return; const anyData = state.wan.some((h) => h.status !== "pending"); if (!anyData) return; const health = state.healthStatus(); const labels = { healthy: "HEALTHY", slow: "SLOW", degraded: "DEGRADED", offline: "OFFLINE", }; const textColors = { healthy: "text-green-400 font-bold", slow: "text-yellow-400 font-bold", degraded: "text-orange-400 font-bold", offline: "text-red-400 font-bold", }; const boxColors = { healthy: "bg-green-900/20 border-green-700/50", slow: "bg-yellow-900/20 border-yellow-700/50", degraded: "bg-orange-900/20 border-orange-700/50", offline: "bg-red-900/20 border-red-700/50", }; el.textContent = labels[health]; el.className = textColors[health]; box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${boxColors[health]}`; if (health === "offline" || health === "degraded") log.warning(`Health: ${labels[health]}`); else if (health === "slow") log.warning(`Health: ${labels[health]}`); else if (health === "healthy") log.notice(`Health: ${labels[health]}`); } // --- Debug Log Renderer ------------------------------------------------------ function renderDebugLog() { const el = document.getElementById("debug-log"); if (!el) return; const levelColors = { error: "text-red-400", warning: "text-orange-400", notice: "text-yellow-400", info: "text-gray-300", debug: "text-gray-500", }; el.innerHTML = debugLog .map((entry) => { const ts = formatUTCTimestamp(entry.timestamp); const cls = levelColors[entry.level] || "text-gray-400"; const lvl = entry.level.toUpperCase().padEnd(7); return `
${ts} ${lvl} ${entry.message}
`; }) .join(""); el.scrollTop = el.scrollHeight; } // --- Sorting ----------------------------------------------------------------- // Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts // by last latency ascending, with unreachable hosts at the bottom. function sortAndRebuildWAN(state) { log.debug("WAN hosts re-sorted"); 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 = wanRowsHTML(state.wan); // 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, false)) .join(""); // Resize canvases and redraw requestAnimationFrame(() => { document.querySelectorAll(".sparkline-canvas").forEach((canvas) => { SparklineRenderer.sizeCanvas(canvas); }); if (state.paused) { state.allHosts.forEach((host, i) => { SparklineRenderer.draw( document.querySelector( `.sparkline-canvas[data-host="${i}"]`, ), host.history, ); }); greyOutUI(state); } else { state.allHosts.forEach((host, i) => { updateHostRow(host, i); }); } }); } // --- Main Loop --------------------------------------------------------------- async function tick(state, onOffline) { const ts = Date.now(); if (state.paused) { log.debug("Tick skipped (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; } log.debug(`Tick #${state.tickCount + 1} started`); const results = await Promise.all( state.allHosts.map((h) => measureLatency(h.url)), ); // User may have paused while awaiting results — discard them if (state.paused) return; state.tickCount++; // Discard the first tick — DNS and TLS setup inflates latencies if (state.tickCount === 1) { log.debug("Tick #1 discarded (cold start)"); return; } state.allHosts.forEach((host, i) => { const r = results[i]; host.pushSample(ts, r); updateHostRow(host, i); log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`); }); // Sort after the first real check, then every 10 ticks thereafter if (state.tickCount === 2 || state.tickCount % 10 === 1) { sortAndRebuildWAN(state); } updateSummary(state); updateHealthBox(state); const reachable = state.allHosts.filter( (h) => h.status === "online", ).length; log.info( `Tick #${state.tickCount} complete: ${reachable}/${state.allHosts.length} reachable`, ); if (state.isHardOffline() && onOffline) { onOffline(); } else { stopRecoveryProbe(state); } } // --- Recovery Probe ---------------------------------------------------------- // When offline, rapidly poll 4 random WAN hosts every 500ms. As soon as any // responds, stop probing and fire a normal tick to refresh all hosts. function startRecoveryProbe(state, triggerTick) { if (state._recoveryProbeId) return; // already running const candidates = [...state.wan]; for (let i = candidates.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; } const canaries = candidates.slice(0, 4); log.notice( `Recovery probe started (${canaries.map((h) => h.name).join(", ")})`, ); state._recoveryProbeId = setInterval(async () => { if (state.paused) return; const results = await Promise.all( canaries.map((h) => measureLatency(h.url)), ); if (results.some((r) => r.error === null)) { log.notice("Recovery probe: connectivity detected"); stopRecoveryProbe(state); triggerTick(); } }, 500); } function stopRecoveryProbe(state) { if (state._recoveryProbeId) { clearInterval(state._recoveryProbeId); state._recoveryProbeId = null; } } // --- Pause / Resume ---------------------------------------------------------- function greyOutUI(state) { // Grey out all host rows state.allHosts.forEach((host, i) => { const latencyEl = document.querySelector( `.latency-value[data-host="${i}"]`, ); const statusEl = document.querySelector( `.status-text[data-host="${i}"]`, ); if (latencyEl) { const val = host.lastLatency !== null ? `${host.lastLatency}ms` : "---"; latencyEl.innerHTML = `${val}`; } if (statusEl) { statusEl.textContent = "paused"; statusEl.className = "status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5"; } // Grey out the status dot const row = document.querySelector(`.host-row[data-index="${i}"]`); if (row) { const dot = row.querySelector(".rounded-full"); if (dot) dot.style.backgroundColor = "#6b7280"; } }); // Grey out summary current stats const reachableEl = document.getElementById("summary-reachable"); if (reachableEl) { reachableEl.textContent = "--/--"; reachableEl.className = "text-gray-500"; } for (const id of [ "summary-min", "summary-med", "summary-avg", "summary-max", ]) { const el = document.getElementById(id); if (el) { el.textContent = "-"; el.className = "text-gray-500"; } const unitEl = document.getElementById(id + "-unit"); if (unitEl) { unitEl.className = "text-gray-500"; } } // Grey out health box const healthText = document.getElementById("health-text"); const healthBox = document.getElementById("health-box"); if (healthText) { healthText.textContent = "---"; healthText.className = "text-gray-500 font-bold"; } if (healthBox) { healthBox.className = "mt-4 p-3 rounded-lg border font-mono text-sm text-center bg-gray-800/70 border-gray-700/50"; } } function togglePause(state) { state.paused = !state.paused; const pauseIcon = document.getElementById("pause-icon"); const playIcon = document.getElementById("play-icon"); const pauseText = document.getElementById("pause-text"); const indicator = document.getElementById("status-indicator"); if (state.paused) { pauseIcon.classList.add("hidden"); playIcon.classList.remove("hidden"); pauseText.textContent = "Resume"; indicator.textContent = "Paused"; indicator.className = "text-yellow-400"; greyOutUI(state); log.notice("Paused"); } else { pauseIcon.classList.remove("hidden"); playIcon.classList.add("hidden"); pauseText.textContent = "Pause"; indicator.textContent = "Running"; indicator.className = "text-green-400"; log.notice("Resumed"); } } // --- 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 --------------------------------------------------------------- async function init() { log.info("NetWatch starting"); // Probe common gateway IPs to find the local router const gateway = await detectGateway(); const localHosts = [LOCAL_CPE]; if (gateway) { localHosts.push(gateway); log.info(`Gateway detected: ${gateway.url}`); } const state = new AppState(localHosts); buildUI(state); log.info("UI built, starting tick loop"); document .getElementById("pause-btn") .addEventListener("click", () => togglePause(state)); document.getElementById("debug-toggle").addEventListener("change", (e) => { const panel = document.getElementById("debug-panel"); if (e.target.checked) { panel.classList.remove("hidden"); renderDebugLog(); } else { panel.classList.add("hidden"); } }); // 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; log.notice(`${host.pinned ? "Pinned" : "Unpinned"} ${host.name}`); sortAndRebuildWAN(state); }); function updateClocks() { const now = new Date(); const pad = (n) => String(n).padStart(2, "0"); const utc = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` + `${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`; const local = now.toLocaleString("sv-SE", { hour12: false }) + " " + new Intl.DateTimeFormat("en-US", { timeZoneName: "shortGeneric" }) .formatToParts(now) .find((p) => p.type === "timeZoneName").value; document.getElementById("clock-local").textContent = "Local: " + local; document.getElementById("clock-utc").textContent = "UTC: " + utc; } updateClocks(); setInterval(updateClocks, 1000); function doTick() { tick(state, () => startRecoveryProbe(state, doTick)); } doTick(); let tickIntervalId = setInterval(doTick, CONFIG.updateInterval); document .getElementById("interval-select") .addEventListener("change", (e) => { const newInterval = parseInt(e.target.value, 10); clearInterval(tickIntervalId); CONFIG.updateInterval = newInterval; log.notice( `Interval changed to ${humanDuration(newInterval / 1000)}, history reset`, ); // Clear all host history and reset state for (const host of state.allHosts) { host.history = []; host.lastLatency = null; host.status = "pending"; } state.tickCount = 0; // Update dynamic text const intervalText = document.getElementById( "update-interval-text", ); if (intervalText) intervalText.textContent = `Updates every ${humanDuration(newInterval / 1000)}`; const historyText = document.getElementById( "history-duration-text", ); if (historyText) historyText.textContent = `History: ${humanDuration(CONFIG.historyDuration)}`; const durationLabel = document.getElementById( "summary-duration-label", ); if (durationLabel) durationLabel.textContent = `${humanDuration(CONFIG.historyDuration)}:`; // Rebuild host rows (resets UI to "waiting..." state) sortAndRebuildWAN(state); // Reset summary and health box updateSummary(state); const healthText = document.getElementById("health-text"); const healthBox = document.getElementById("health-box"); if (healthText) { healthText.textContent = "Waiting for data..."; healthText.className = "text-gray-400"; } if (healthBox) { healthBox.className = "mt-4 p-3 rounded-lg border font-mono text-sm text-center bg-gray-800/70 border-gray-700/50"; } // Start immediately with new interval stopRecoveryProbe(state); doTick(); tickIntervalId = setInterval(doTick, CONFIG.updateInterval); }); window.addEventListener("resize", () => handleResize(state)); setTimeout(() => handleResize(state), 100); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); }