From de98e74539117294e9078ad2d8cb61986dc14948 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 26 Feb 2026 17:23:30 +0700 Subject: [PATCH] Add debug log panel, median stats, recovery probe, and UI improvements - Debug log: togglable panel with timestamped, level-tagged, color-coded entries (error/warning/notice/info/debug) from throughout the app - Median latency: added to per-host stats and summary (min/med/avg/max) - Recovery probe: rapid 500ms polling of 4 random hosts when hard offline, triggers normal tick as soon as connectivity returns - Health status: multi-level (healthy/slow/degraded/offline) with hard-offline detection for recovery probe activation - First tick discarded to avoid DNS/TLS cold-start latency skew - Added Google, S3 ap-southeast-1 (Singapore) to monitored hosts - UI: reduced row padding, larger sparkline canvas, bigger axis labels, pin icon hidden (but space preserved) for local network hosts - Commit hash shown in footer via vite define plugin --- src/main.js | 632 ++++++++++++++++++++++++++++++++++++++++++------- vite.config.js | 8 + 2 files changed, 553 insertions(+), 87 deletions(-) diff --git a/src/main.js b/src/main.js index ea9632f..45e8f2f 100644 --- a/src/main.js +++ b/src/main.js @@ -7,23 +7,26 @@ import "./styles.css"; // graphMaxLatency — values above it pin to the top of the chart but still // display their real value in the latency figure. The history buffer holds // maxHistoryPoints samples (historyDuration / updateInterval). -const CONFIG = Object.freeze({ +const CONFIG = { updateInterval: 3000, - historyDuration: 300, + maxHistoryPoints: 100, + get historyDuration() { + return (this.maxHistoryPoints * this.updateInterval) / 1000; + }, get requestTimeout() { - return this.updateInterval - 50; + return Math.min(this.updateInterval - 100, 3000); }, get maxLatency() { return this.requestTimeout; }, graphMaxLatency: 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); + 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 @@ -38,6 +41,7 @@ const WAN_HOSTS = [ { 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" }, { @@ -56,6 +60,10 @@ const WAN_HOSTS = [ 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", @@ -95,6 +103,26 @@ const WAN_HOSTS = [ }, ]; +// --- 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" }; @@ -112,6 +140,26 @@ const GATEWAY_CANDIDATES = [ "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 @@ -140,7 +188,7 @@ async function detectGateway() { ); return result; } catch { - // All candidates failed + log.error("All gateway candidates failed"); return null; } } @@ -183,6 +231,30 @@ class HostState { ); } + 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(); @@ -209,19 +281,33 @@ class AppState { 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: 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 300s window) */ + /** Min/max across all WAN host history (the full history window) */ wanHistoryStats() { let min = Infinity; let max = -Infinity; @@ -237,10 +323,26 @@ class AppState { return { min, max }; } - /** 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; + /** 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", + ); } } @@ -267,13 +369,18 @@ async function measureLatency(url) { }); const latency = Math.round(performance.now() - start); clearTimeout(timeoutId); - if (latency > CONFIG.maxLatency) + 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") + if (err.name === "AbortError") { + log.error(`${url} timeout (aborted)`); return { latency: null, error: "timeout" }; + } + log.error(`${url} unreachable`); return { latency: null, error: "unreachable" }; } } @@ -302,7 +409,7 @@ function latencyClass(latency, status) { // --- Sparkline Renderer ------------------------------------------------------ class SparklineRenderer { - static MARGIN = { left: 35, right: 10, top: 5, bottom: 18 }; + static MARGIN = { left: 42, right: 10, top: 5, bottom: 22 }; static draw(canvas, history) { const ctx = canvas.getContext("2d"); @@ -332,7 +439,7 @@ class SparklineRenderer { } static _drawYAxis(ctx, w, h, m, ch) { - ctx.font = "9px monospace"; + ctx.font = "300 12px monospace"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (const tick of CONFIG.yAxisTicks) { @@ -354,7 +461,7 @@ class SparklineRenderer { 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(`-${tick}s`, x, h - m.bottom + 4); + ctx.fillText(`-${humanDuration(tick)}`, x, h - m.bottom + 4); } } @@ -430,18 +537,22 @@ class SparklineRenderer { // --- UI Renderer ------------------------------------------------------------- -function hostRowHTML(host, index) { +function hostRowHTML(host, index, showPin = true) { const pinColor = host.pinned - ? "text-blue-400" + ? "text-blue-500" : "text-gray-600 hover:text-gray-400"; - return ` -
-
- ` + : `
`; + return ` +
+
+ ${pinBtn}
@@ -449,11 +560,11 @@ function hostRowHTML(host, index) {
${host.url}
-
+
---
-
waiting...
+
waiting...
@@ -462,28 +573,56 @@ function hostRowHTML(host, index) {
`; } +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

- +

NetWatch by @sneak

+
+ +
+ + +
+

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

@@ -496,26 +635,34 @@ function buildUI(state) { Reachable: --/-- | Now: - --/--/--ms + min --ms + / med --ms + / avg --ms + / max --ms | - ${CONFIG.historyDuration}s: - --/--ms + ${humanDuration(CONFIG.historyDuration)}: + min --ms + / max --ms | Checks: 0

- ${state.wan.map((h, i) => hostRowHTML(h, i)).join("")} + ${wanRowsHTML(state.wan)}

Local Network

- ${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i)).join("")} + ${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i, false)).join("")} +
+ +
-

Latency measured via HEAD requests | IPv4 only | CORS restrictions may affect some measurements

+

Latency measured via GET requests | IPv4 only | CORS restrictions may affect some measurements

<50ms <100ms @@ -524,6 +671,13 @@ function buildUI(state) { >500ms offline

+

${__COMMIT_HASH__}

+

+ +

`; @@ -556,18 +710,31 @@ function updateHostRow(host, index) { } const avg = host.averageLatency(); + const med = host.medianLatency(); + const min = host.minLatency(); + const max = host.maxLatency(); if (host.status === "online" && avg !== null) { - statusEl.textContent = `avg: ${avg}ms`; - statusEl.className = `status-text text-xs mt-1 ${latencyClass(avg, "online")}`; + statusEl.innerHTML = + `min ${min}ms` + + ` / ` + + `med ${med}ms` + + ` / ` + + `avg ${avg}ms` + + ` / ` + + `max ${max}ms`; + statusEl.className = "status-text text-xs mt-1 whitespace-nowrap"; } else if (host.status === "offline") { 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 whitespace-nowrap"; } else if (host.status === "error") { 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 whitespace-nowrap"; } else { 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 whitespace-nowrap"; } SparklineRenderer.draw(canvas, host.history); @@ -579,10 +746,17 @@ function updateSummary(state) { const reachableEl = document.getElementById("summary-reachable"); const minEl = document.getElementById("summary-min"); - const maxEl = document.getElementById("summary-max"); + 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}`; @@ -596,29 +770,45 @@ function updateSummary(state) { 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"); - maxEl.textContent = `${stats.max}ms`; + 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"; - maxEl.textContent = "--ms"; + 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"); - hmaxEl.textContent = `${hstats.max}ms`; + 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"; - hmaxEl.textContent = "--ms"; + hminUnitEl.className = "text-gray-500"; + hmaxEl.textContent = "--"; hmaxEl.className = "text-gray-500"; + hmaxUnitEl.className = "text-gray-500"; } const checksEl = document.getElementById("summary-checks"); @@ -633,16 +823,56 @@ function updateHealthBox(state) { 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" - }`; + 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 ----------------------------------------------------------------- @@ -650,6 +880,7 @@ function updateHealthBox(state) { // 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)); @@ -663,12 +894,12 @@ function sortAndRebuildWAN(state) { state.wan = [...pinned, ...rest]; const container = document.getElementById("wan-hosts"); - container.innerHTML = state.wan.map((h, i) => hostRowHTML(h, i)).join(""); + 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)) + .map((h, i) => hostRowHTML(h, state.wan.length + i, false)) .join(""); // Resize canvases and redraw @@ -676,18 +907,31 @@ function sortAndRebuildWAN(state) { document.querySelectorAll(".sparkline-canvas").forEach((canvas) => { SparklineRenderer.sizeCanvas(canvas); }); - state.allHosts.forEach((host, i) => { - updateHostRow(host, i); - }); + 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) { +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); @@ -702,27 +946,152 @@ async function tick(state) { return; } + log.debug(`Tick #${state.tickCount + 1} started`); + const results = await Promise.all( state.allHosts.map((h) => measureLatency(h.url)), ); - state.allHosts.forEach((host, i) => { - host.pushSample(ts, results[i]); - updateHostRow(host, i); - }); + // User may have paused while awaiting results — discard them + if (state.paused) return; state.tickCount++; - // Sort after the first check, then every 5 checks thereafter - if (state.tickCount === 1 || state.tickCount % 5 === 1) { + + // 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 mt-1 whitespace-nowrap"; + } + // 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"); @@ -736,12 +1105,15 @@ function togglePause(state) { 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"); } } @@ -758,18 +1130,34 @@ function handleResize(state) { // --- 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); + 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"); @@ -778,21 +1166,91 @@ async function init() { 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 utc = now.toISOString().replace(/\.\d{3}Z$/, "Z"); - const local = now.toLocaleString("sv-SE", { hour12: false }); + 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); - tick(state); - setInterval(() => tick(state), CONFIG.updateInterval); + 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); diff --git a/vite.config.js b/vite.config.js index 2cc1413..92cd874 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,16 @@ import { defineConfig } from "vite"; import tailwindcss from "@tailwindcss/vite"; +import { execSync } from "child_process"; + +const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); +const commitFull = execSync("git rev-parse HEAD").toString().trim(); export default defineConfig({ plugins: [tailwindcss()], + define: { + __COMMIT_HASH__: JSON.stringify(commitHash), + __COMMIT_FULL__: JSON.stringify(commitFull), + }, build: { target: "esnext", minify: "esbuild",