Files
netwatch/src/main.js
T
clawbot 161f955ae2
check / check (push) Successful in 2m50s
Latency statistics written once, thresholds read from CONFIG (closes #102)
A target's min, max, median and average latency now come from one list
of its answers, through latencyStats(), which the summary's figures use
too, so the median is written once. latencyHex() and latencyClass() read
one table of color limits in CONFIG. The health thresholds, the debug
log's length, the gateway check's timeout, the recovery probe's number
of hosts and interval, how often the rows are sorted and the delay
before the first sparkline resize are CONFIG entries, read where the
numbers were. HostState's minLatency(), maxLatency(), averageLatency()
and medianLatency() are gone; the statistics test reads historyStats().
Nothing the page does or shows changes. A unit test now covers the
summary's figures.

Model: opus-5-5
2026-10-04 06:51:24 +02:00

1524 lines
58 KiB
JavaScript

// --- Configuration -----------------------------------------------------------
// Timing, axis labels, and display constants. A target check times out
// after requestTimeout, 80% of updateInterval, so a round's checks have
// all finished before the next round is due; latency above maxLatency is
// recorded as a timeout. 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.
// The interval menu changes updateInterval while the page runs; the
// getters compute their values from it each time they are read.
export const CONFIG = {
updateInterval: 3000,
maxHistoryPoints: 100,
reportInterval: 60000,
get historyDuration() {
return (this.maxHistoryPoints * this.updateInterval) / 1000;
},
get requestTimeout() {
return this.updateInterval * 0.8;
},
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,
// A latency figure and its sparkline take the color of the first entry
// whose limit, in ms, the latency is below.
latencyColors: [
{ below: 50, hex: "#22c55e", className: "text-green-500" },
{ below: 100, hex: "#84cc16", className: "text-lime-500" },
{ below: 200, hex: "#eab308", className: "text-yellow-500" },
{ below: 500, hex: "#f97316", className: "text-orange-500" },
{ below: Infinity, hex: "#ef4444", className: "text-red-500" },
],
// The health is offline when more than offlineTimeouts WAN hosts timed
// out or were unreachable and at most offlineReachable answered;
// otherwise degraded when more than degradedTimeouts timed out or were
// unreachable; otherwise slow when more than slowHosts answered after
// more than slowLatency ms.
offlineTimeouts: 10,
offlineReachable: 4,
degradedTimeouts: 4,
slowHosts: 3,
slowLatency: 1000,
// The debug log keeps its last maxLogEntries lines.
maxLogEntries: 1000,
// A gateway candidate that has not answered after gatewayTimeout ms is
// passed over.
gatewayTimeout: 1500,
// When no WAN host answers, the recovery probe checks recoveryProbeHosts
// random ones every recoveryProbeInterval ms.
recoveryProbeHosts: 4,
recoveryProbeInterval: 500,
// The rows are sorted after the first round that is not discarded, then
// every roundsPerSort rounds.
roundsPerSort: 10,
// The sparklines are sized and drawn again resizeDelay ms after start.
resizeDelay: 100,
};
// 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 > CONFIG.maxLogEntries)
debugLog.splice(0, debugLog.length - CONFIG.maxLogEntries);
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 -----------------------------------------------------
export 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(),
CONFIG.gatewayTimeout,
);
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 ---------------------------------------------------------------
// The min, max, median and average of latencies, a list of numbers, or all
// null when it is empty. The median of an even count is the mean of the
// middle two; it and the average are rounded.
function latencyStats(latencies) {
if (latencies.length === 0)
return { min: null, max: null, med: null, avg: null };
const sorted = [...latencies].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return {
min: sorted[0],
max: sorted[sorted.length - 1],
med:
sorted.length % 2
? sorted[mid]
: Math.round((sorted[mid - 1] + sorted[mid]) / 2),
avg: Math.round(
latencies.reduce((a, b) => a + b, 0) / latencies.length,
),
};
}
export class HostState {
constructor(host, pinned = false) {
this.name = host.name;
this.url = host.url;
// Each entry is either a check's result, { timestamp, latency,
// error }, or a round skipped while paused, { timestamp,
// latency: null, paused: true }.
this.history = [];
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();
}
// The min, max, median and average latency of the checks in the history
// that got an answer.
historyStats() {
return latencyStats(
this.history
.filter((p) => p.latency !== null)
.map((p) => p.latency),
);
}
_trim() {
while (this.history.length > CONFIG.maxHistoryPoints)
this.history.shift();
}
}
export 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;
// The recovery probe's timer, null while it is not running, and the
// checks it started last.
this._recoveryProbeId = null;
this._recoveryProbeChecks = null;
}
get allHosts() {
return [...this.wan, ...this.local];
}
/** WAN-only stats from latest sample (excludes local) */
wanStats() {
const latencies = this.wan
.filter((h) => h.lastLatency !== null)
.map((h) => h.lastLatency);
return {
reachable: latencies.length,
total: this.wan.length,
...latencyStats(latencies),
};
}
/** 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 > CONFIG.offlineTimeouts &&
reachable <= CONFIG.offlineReachable
)
return "offline";
if (timeouts > CONFIG.degradedTimeouts) return "degraded";
const slow = this.wan.filter(
(h) => h.lastLatency !== null && h.lastLatency > CONFIG.slowLatency,
).length;
if (slow > CONFIG.slowHosts) 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 UUIDv4. `crypto.randomUUID` exists only in secure contexts
// (HTTPS or localhost); over plain HTTP to any other host — the normal LAN
// deployment — it is undefined, so feature-detect it and otherwise build the
// id from `crypto.getRandomValues`, which is available in insecure contexts.
function randomId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
return (
hex.slice(0, 4).join("") +
"-" +
hex.slice(4, 6).join("") +
"-" +
hex.slice(6, 8).join("") +
"-" +
hex.slice(8, 10).join("") +
"-" +
hex.slice(10, 16).join("")
);
}
// 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 = randomId();
localStorage.setItem(key, id);
}
return id;
} catch {
return randomId();
}
}
// 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.
export 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.
//
// Only one report is ever in flight, and it is abandoned after half the
// interval, so a slow POST can neither overlap the next report (which would
// carry the same samples) nor stall reporting for good.
class Reporter {
constructor(state, clientId, intervalMs) {
this.state = state;
this.clientId = clientId;
this.intervalMs = intervalMs;
this.marks = new Map();
this.failing = false;
this.sending = false;
this.timerId = null;
}
start() {
if (this.timerId) return;
this.timerId = setInterval(() => this.flush(), this.intervalMs);
}
async flush() {
if (this.sending || this.state.paused) return;
const report = buildReport(
this.state.allHosts,
this.clientId,
new Date(),
this.marks,
);
if (!report) return;
this.sending = true;
try {
const resp = await fetch("/api/v1/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify(report.body),
signal: AbortSignal.timeout(this.intervalMs / 2),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
for (const [url, t] of report.marks) {
const current = this.marks.get(url) ?? 0;
this.marks.set(url, Math.max(current, 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;
}
} finally {
this.sending = false;
}
}
}
// --- Latency Measurement -----------------------------------------------------
// Checks one target. The check times out after CONFIG.requestTimeout; the
// caller can give it up sooner through the optional signal, which also ends
// it as a timeout.
export async function measureLatency(url, signal) {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
CONFIG.requestTimeout,
);
signal?.addEventListener("abort", () => controller.abort());
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 -----------------------------------------------------------
export function latencyHex(latency) {
if (latency === null) return "#6b7280";
return CONFIG.latencyColors.find((c) => latency < c.below).hex;
}
export function latencyClass(latency, status) {
if (status === "offline" || status === "error" || latency === null)
return "text-gray-500";
return CONFIG.latencyColors.find((c) => latency < c.below).className;
}
// --- 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, m, ch);
SparklineRenderer._drawXAxis(ctx, 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, 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, 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";
// Escapes text for HTML, so it shows as written inside an element or a
// quoted attribute and is never read as markup.
function escapeHTML(text) {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export 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
? `<button class="pin-btn flex-shrink-0 ${pinColor} transition-colors" data-pin="${index}" title="Pin to top">
<svg class="w-4 h-4 ${pinRotate}" fill="currentColor" viewBox="0 0 16 16">
<path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5a.5.5 0 0 1-1 0V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354"/>
</svg>
</button>`
: `<div class="flex-shrink-0 w-4 h-4"></div>`;
return `
<div class="host-row bg-gray-800/50 rounded-lg p-2 border border-gray-700/50" data-index="${index}">
<div class="flex items-center gap-4 min-w-0">
${pinBtn}
<div class="w-[420px] flex-shrink-0 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div class="flex items-center gap-2 min-w-[200px]">
<div class="w-3 h-3 rounded-full flex-shrink-0 bg-[#6b7280]"></div>
<span class="font-medium text-white truncate">${escapeHTML(host.name)}</span>
</div>
<div class="latency-value text-4xl font-bold tabular-nums text-right mt-3" data-host="${index}">
<span class="text-gray-500">---</span>
</div>
<a href="${escapeHTML(host.url)}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${escapeHTML(host.url)}</a>
<div class="${STATUS_TEXT_CLASS} text-gray-500" data-host="${index}">waiting...</div>
</div>
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
</div>
</div>
</div>`;
}
function 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,
`<div class="my-9 flex justify-center"><hr class="w-1/2 border-t border-gray-600"></div>`,
);
}
return rows.join("");
}
function buildUI(state) {
const app = document.getElementById("app");
app.innerHTML = `
<div class="mx-auto px-[5%] py-8">
<header class="mb-8">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white"><a href="https://git.eeqj.de/sneak/netwatch" target="_blank" rel="noopener" class="underline decoration-dashed decoration-gray-500 underline-offset-4">NetWatch</a> by <a href="https://sneak.berlin" target="_blank" rel="noopener" class="text-blue-400 underline hover:text-blue-300">@sneak</a></h1>
<div class="flex flex-col items-end gap-2">
<button id="pause-btn"
class="flex items-center gap-3 px-6 py-3 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500">
<svg id="pause-icon" class="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>
</svg>
<svg id="play-icon" class="w-8 h-8 text-green-400 hidden" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span id="pause-text" class="text-lg font-semibold text-white">Pause</span>
</button>
<div class="flex items-center gap-2">
<label class="text-gray-400 text-sm">Interval:</label>
<select id="interval-select" class="bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer">
<option value="1000">1s</option>
<option value="2000">2s</option>
<option value="3000" selected>3s</option>
<option value="5000">5s</option>
<option value="10000">10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</div>
</div>
</div>
<p class="text-gray-400 text-sm">
Real-time network latency monitor |
<span id="update-interval-text" class="text-gray-500">Updates every ${humanDuration(CONFIG.updateInterval / 1000)}</span> |
<span id="history-duration-text" class="text-gray-500">History: ${humanDuration(CONFIG.historyDuration)}</span> |
<span id="status-indicator" class="text-green-400">Running</span>
</p>
<p class="text-gray-500 text-xs font-mono mt-1">
<span id="clock-local"></span> | <span id="clock-utc"></span>
</p>
<div id="health-box" class="mt-4 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm text-center">
<span id="health-text" class="text-gray-400">Waiting for data...</span>
</div>
<div id="summary" class="mt-2 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm">
<span class="text-gray-400">Reachable:</span> <span id="summary-reachable" class="text-white">--/--</span>
<span class="text-gray-600 mx-3">|</span>
<span class="text-gray-400">Now:</span>
<span class="text-gray-400"> min </span><span id="summary-min" class="text-green-400">--</span><span id="summary-min-unit" class="text-green-400">ms</span>
<span class="text-gray-400"> / med </span><span id="summary-med" class="text-blue-400">--</span><span id="summary-med-unit" class="text-blue-400">ms</span>
<span class="text-gray-400"> / avg </span><span id="summary-avg" class="text-yellow-400">--</span><span id="summary-avg-unit" class="text-yellow-400">ms</span>
<span class="text-gray-400"> / max </span><span id="summary-max" class="text-red-400">--</span><span id="summary-max-unit" class="text-red-400">ms</span>
<span class="text-gray-600 mx-3">|</span>
<span id="summary-duration-label" class="text-gray-400">${humanDuration(CONFIG.historyDuration)}:</span>
<span class="text-gray-400"> min </span><span id="summary-hmin" class="text-green-400">--</span><span id="summary-hmin-unit" class="text-green-400">ms</span>
<span class="text-gray-400"> / max </span><span id="summary-hmax" class="text-red-400">--</span><span id="summary-hmax-unit" class="text-red-400">ms</span>
<span class="text-gray-600 mx-3">|</span>
<span class="text-gray-400">Checks:</span> <span id="summary-checks" class="text-gray-300">0</span>
</div>
</header>
<div id="wan-hosts" class="space-y-4">
${wanRowsHTML(state.wan)}
</div>
<h2 class="text-gray-500 text-xs uppercase tracking-wide mt-6 mb-3">Local Network</h2>
<div id="local-hosts" class="space-y-4">
${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i, false)).join("")}
</div>
<div id="debug-panel" class="hidden mt-6 mb-2">
<div class="bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-xs text-gray-400 h-[1280px] overflow-y-auto" id="debug-log"></div>
</div>
<footer class="mt-8 text-center text-gray-600 text-xs">
<p>Latency measured via GET requests | IPv4 only | CORS restrictions may affect some measurements</p>
<p class="mt-2">
<span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span>&lt;50ms
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span>&lt;100ms
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500 mr-1 ml-3 align-middle"></span>&lt;200ms
<span class="inline-block w-3 h-3 rounded-full bg-orange-500 mr-1 ml-3 align-middle"></span>&lt;500ms
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>&gt;500ms
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p>
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
<p class="mt-2">
<label class="debug-toggle-label cursor-pointer">
<input type="checkbox" id="debug-toggle" class="mr-1">
<span>Debug log</span>
</label>
</p>
</footer>
</div>`;
requestAnimationFrame(() => {
document
.querySelectorAll(".sparkline-canvas")
.forEach((c) => SparklineRenderer.sizeCanvas(c));
});
}
// --- UI Updaters -------------------------------------------------------------
// Renders `min 1ms / med 2ms / avg 3ms / max 4ms`. Each label, value and
// trailing separator is one unbreakable unit, so wrapping only ever happens
// between stats and a wrapped line never starts with a separator.
function statusStatsHTML(stats) {
return stats
.map(([label, value], i) => {
const sep =
i < stats.length - 1
? ` <span class="text-gray-500">/</span>`
: "";
return (
`<span class="whitespace-nowrap">` +
`<span class="text-gray-400">${label} </span>` +
`<span class="${latencyClass(value, "online")}">${value}ms</span>` +
`${sep}</span>`
);
})
.join(" ");
}
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 = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
} else if (host.status === "offline" || host.status === "error") {
latencyEl.innerHTML = `<span class="text-gray-500">---</span>`;
}
const { min, med, avg, max } = host.historyStats();
if (host.status === "online" && avg !== null) {
statusEl.innerHTML = statusStatsHTML([
["min", min],
["med", med],
["avg", avg],
["max", max],
]);
statusEl.className = STATUS_TEXT_CLASS;
} else if (host.status === "offline") {
statusEl.textContent = "unreachable";
statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
} else if (host.status === "error") {
statusEl.textContent = "timeout";
statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
} else {
statusEl.textContent = "connecting...";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
}
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.replaceChildren(
...debugLog.map((entry) => {
const ts = formatUTCTimestamp(entry.timestamp);
const lvl = entry.level.toUpperCase().padEnd(7);
const line = document.createElement("div");
line.className = levelColors[entry.level] || "text-gray-400";
line.textContent = `${ts} ${lvl} ${entry.message}`;
return line;
}),
);
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 ---------------------------------------------------------------
export async function tick(state, signal, 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`);
// Each host's row shows its result as soon as its check ends. The
// result is discarded if by then the user has paused or the next round
// has given up this one's checks, and in the first tick (tickCount is
// still 0), which is discarded as a whole below. The row is looked up
// when the check ends, as a pin click may have re-sorted the rows since
// the round started.
await Promise.all(
state.allHosts.map(async (host) => {
const r = await measureLatency(host.url, signal);
if (state.paused || signal.aborted || state.tickCount === 0) {
return;
}
host.pushSample(ts, r);
updateHostRow(host, state.allHosts.indexOf(host));
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
}),
);
// User may have paused, or the next round may have given up this
// one's checks, while awaiting results — skip the rest of the round
if (state.paused || signal.aborted) 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;
}
// Redraw every row: if the user paused and resumed during this round,
// rows whose check ended before the resume still read "paused"
state.allHosts.forEach((host, i) => updateHostRow(host, i));
// Sort after the first real check, then every CONFIG.roundsPerSort
// ticks thereafter
if (state.tickCount === 2 || state.tickCount % CONFIG.roundsPerSort === 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, check CONFIG.recoveryProbeHosts random WAN hosts every
// CONFIG.recoveryProbeInterval ms, giving up the checks started one interval
// before, so at most that many are ever waiting. As soon as one answers,
// stop probing and start a new round at once.
function startRecoveryProbe(state, startRounds) {
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, CONFIG.recoveryProbeHosts);
log.notice(
`Recovery probe started (${canaries.map((h) => h.name).join(", ")})`,
);
state._recoveryProbeId = setInterval(() => {
if (state.paused) return;
state._recoveryProbeChecks?.abort();
const checks = new AbortController();
state._recoveryProbeChecks = checks;
for (const host of canaries) {
measureLatency(host.url, checks.signal).then((r) => {
if (r.error !== null || checks.signal.aborted) return;
log.notice("Recovery probe: connectivity detected");
stopRecoveryProbe(state);
startRounds();
});
}
}, CONFIG.recoveryProbeInterval);
}
function stopRecoveryProbe(state) {
if (state._recoveryProbeId) {
clearInterval(state._recoveryProbeId);
state._recoveryProbeId = null;
state._recoveryProbeChecks?.abort();
}
}
// --- Pause / Resume ----------------------------------------------------------
export 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 = `<span class="text-gray-500">${val}</span>`;
}
if (statusEl) {
statusEl.textContent = "paused";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
}
// 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");
// Reporting is best-effort: any failure setting it up (e.g. no usable
// crypto for the client id) must never stop the monitor from probing.
try {
const reporter = new Reporter(
state,
getClientId(),
CONFIG.reportInterval,
);
reporter.start();
} catch (err) {
log.error(`Reporting disabled: ${err.message}`);
}
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);
// Rounds never overlap: a round first gives up the last round's checks
// if they are still waiting, and the last round then records nothing
// more. At a steady interval they never are, as they time out at 80% of
// it; they can be when a round starts early, after an interval change
// or when the recovery probe finds a target answering.
let roundChecks = new AbortController();
function doTick() {
roundChecks.abort();
roundChecks = new AbortController();
tick(state, roundChecks.signal, () =>
startRecoveryProbe(state, startRounds),
);
}
// Starts a round now and then one every CONFIG.updateInterval.
let tickIntervalId;
function startRounds() {
clearInterval(tickIntervalId);
doTick();
tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
}
startRounds();
document
.getElementById("interval-select")
.addEventListener("change", (e) => {
const newInterval = parseInt(e.target.value, 10);
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);
startRounds();
});
window.addEventListener("resize", () => handleResize(state));
setTimeout(() => handleResize(state), CONFIG.resizeDelay);
}
// Bootstrap only when loaded as the page: a real DOM containing the #app
// mount point this module renders into. Importing the module in a unit test
// (which has no #app) runs nothing, so its exports can be tested in isolation.
if (typeof document !== "undefined" && document.getElementById("app")) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}