`;
})
.join("");
el.scrollTop = el.scrollHeight;
}
// --- Sorting -----------------------------------------------------------------
// Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts
// by last latency ascending, with unreachable hosts at the bottom.
function sortAndRebuildWAN(state) {
log.debug("WAN hosts re-sorted");
const pinned = state.wan
.filter((h) => h.pinned)
.sort((a, b) => a.name.localeCompare(b.name));
const rest = state.wan.filter((h) => !h.pinned);
rest.sort((a, b) => {
if (a.lastLatency === null && b.lastLatency === null) return 0;
if (a.lastLatency === null) return 1;
if (b.lastLatency === null) return -1;
return a.lastLatency - b.lastLatency;
});
state.wan = [...pinned, ...rest];
const container = document.getElementById("wan-hosts");
container.innerHTML = wanRowsHTML(state.wan);
// Re-index local hosts after WAN
const localContainer = document.getElementById("local-hosts");
localContainer.innerHTML = state.local
.map((h, i) => hostRowHTML(h, state.wan.length + i, false))
.join("");
// Resize canvases and redraw
requestAnimationFrame(() => {
document.querySelectorAll(".sparkline-canvas").forEach((canvas) => {
SparklineRenderer.sizeCanvas(canvas);
});
if (state.paused) {
state.allHosts.forEach((host, i) => {
SparklineRenderer.draw(
document.querySelector(
`.sparkline-canvas[data-host="${i}"]`,
),
host.history,
);
});
greyOutUI(state);
} else {
state.allHosts.forEach((host, i) => {
updateHostRow(host, i);
});
}
});
}
// --- Main Loop ---------------------------------------------------------------
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 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, check 4 random WAN hosts every 500ms, giving up the checks
// started 500ms before, so at most 4 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, 4);
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();
});
}
}, 500);
}
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 = `${val}`;
}
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), 100);
}
// 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();
}
}