+
---
-
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
-
+
+
+
+
+
+
+
+
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("")}
+
+
+
`;
@@ -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",