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
This commit is contained in:
632
src/main.js
632
src/main.js
@@ -7,23 +7,26 @@ import "./styles.css";
|
|||||||
// graphMaxLatency — values above it pin to the top of the chart but still
|
// 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
|
// display their real value in the latency figure. The history buffer holds
|
||||||
// maxHistoryPoints samples (historyDuration / updateInterval).
|
// maxHistoryPoints samples (historyDuration / updateInterval).
|
||||||
const CONFIG = Object.freeze({
|
const CONFIG = {
|
||||||
updateInterval: 3000,
|
updateInterval: 3000,
|
||||||
historyDuration: 300,
|
maxHistoryPoints: 100,
|
||||||
|
get historyDuration() {
|
||||||
|
return (this.maxHistoryPoints * this.updateInterval) / 1000;
|
||||||
|
},
|
||||||
get requestTimeout() {
|
get requestTimeout() {
|
||||||
return this.updateInterval - 50;
|
return Math.min(this.updateInterval - 100, 3000);
|
||||||
},
|
},
|
||||||
get maxLatency() {
|
get maxLatency() {
|
||||||
return this.requestTimeout;
|
return this.requestTimeout;
|
||||||
},
|
},
|
||||||
graphMaxLatency: 1000,
|
graphMaxLatency: 1000,
|
||||||
yAxisTicks: [0, 250, 500, 750, 1000],
|
yAxisTicks: [0, 250, 500, 750, 1000],
|
||||||
xAxisTicks: [0, 60, 120, 180, 240, 300],
|
get xAxisTicks() {
|
||||||
canvasHeight: 80,
|
const d = this.historyDuration;
|
||||||
get maxHistoryPoints() {
|
return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5));
|
||||||
return Math.ceil((this.historyDuration * 1000) / this.updateInterval);
|
|
||||||
},
|
},
|
||||||
});
|
canvasHeight: 96,
|
||||||
|
};
|
||||||
|
|
||||||
// WAN endpoints to monitor. These are used for the aggregate health/stats
|
// WAN endpoints to monitor. These are used for the aggregate health/stats
|
||||||
// display (min/max/avg). Ordered: personal, LLM APIs, big-3 cloud, then
|
// 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: "Cloudflare", url: "https://www.cloudflare.com" },
|
||||||
{ name: "Fastly CDN", url: "https://www.fastly.com" },
|
{ name: "Fastly CDN", url: "https://www.fastly.com" },
|
||||||
{ name: "Akamai", url: "https://www.akamai.com" },
|
{ name: "Akamai", url: "https://www.akamai.com" },
|
||||||
|
{ name: "Google", url: "https://www.google.com" },
|
||||||
{ name: "GitHub", url: "https://github.com" },
|
{ name: "GitHub", url: "https://github.com" },
|
||||||
{ name: "B2", url: "https://api.backblazeb2.com" },
|
{ name: "B2", url: "https://api.backblazeb2.com" },
|
||||||
{
|
{
|
||||||
@@ -56,6 +60,10 @@ const WAN_HOSTS = [
|
|||||||
name: "S3 ap-northeast-1 (Tokyo)",
|
name: "S3 ap-northeast-1 (Tokyo)",
|
||||||
url: "https://s3.ap-northeast-1.amazonaws.com",
|
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)",
|
name: "S3 ap-southeast-2 (Sydney)",
|
||||||
url: "https://s3.ap-southeast-2.amazonaws.com",
|
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.
|
// The cable modem / CPE upstream of the local gateway — always monitored.
|
||||||
const LOCAL_CPE = { name: "Local CPE", url: "http://192.168.100.1" };
|
const LOCAL_CPE = { name: "Local CPE", url: "http://192.168.100.1" };
|
||||||
|
|
||||||
@@ -112,6 +140,26 @@ const GATEWAY_CANDIDATES = [
|
|||||||
"http://10.0.0.1",
|
"http://10.0.0.1",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- Formatting Helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
function formatUTCTimestamp(date) {
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `[${date.getUTCFullYear()}-${p(date.getUTCMonth() + 1)}-${p(date.getUTCDate())} ${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duration Formatting -----------------------------------------------------
|
||||||
|
|
||||||
|
function humanDuration(seconds) {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
let result = "";
|
||||||
|
if (h) result += `${h}h`;
|
||||||
|
if (m) result += `${m}m`;
|
||||||
|
if (s || !result) result += `${s}s`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Gateway Detection -------------------------------------------------------
|
// --- Gateway Detection -------------------------------------------------------
|
||||||
|
|
||||||
// Probe each gateway candidate with a short timeout. Returns the first one
|
// Probe each gateway candidate with a short timeout. Returns the first one
|
||||||
@@ -140,7 +188,7 @@ async function detectGateway() {
|
|||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
// All candidates failed
|
log.error("All gateway candidates failed");
|
||||||
return null;
|
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() {
|
_trim() {
|
||||||
while (this.history.length > CONFIG.maxHistoryPoints)
|
while (this.history.length > CONFIG.maxHistoryPoints)
|
||||||
this.history.shift();
|
this.history.shift();
|
||||||
@@ -209,19 +281,33 @@ class AppState {
|
|||||||
const latencies = reachable.map((h) => h.lastLatency);
|
const latencies = reachable.map((h) => h.lastLatency);
|
||||||
const total = this.wan.length;
|
const total = this.wan.length;
|
||||||
if (latencies.length === 0)
|
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 {
|
return {
|
||||||
reachable: latencies.length,
|
reachable: latencies.length,
|
||||||
total,
|
total,
|
||||||
min: Math.min(...latencies),
|
min: Math.min(...latencies),
|
||||||
max: Math.max(...latencies),
|
max: Math.max(...latencies),
|
||||||
|
med,
|
||||||
avg: Math.round(
|
avg: Math.round(
|
||||||
latencies.reduce((a, b) => a + b, 0) / latencies.length,
|
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() {
|
wanHistoryStats() {
|
||||||
let min = Infinity;
|
let min = Infinity;
|
||||||
let max = -Infinity;
|
let max = -Infinity;
|
||||||
@@ -237,10 +323,26 @@ class AppState {
|
|||||||
return { min, max };
|
return { min, max };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Overall health: true = healthy (more than half WAN reachable) */
|
/** Overall health: "healthy" | "slow" | "degraded" | "offline" */
|
||||||
isHealthy() {
|
healthStatus() {
|
||||||
const reachable = this.wan.filter((h) => h.lastLatency !== null).length;
|
const reachable = this.wan.filter((h) => h.status === "online").length;
|
||||||
return reachable > this.wan.length / 2;
|
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);
|
const latency = Math.round(performance.now() - start);
|
||||||
clearTimeout(timeoutId);
|
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: null, error: "timeout" };
|
||||||
|
}
|
||||||
return { latency, error: null };
|
return { latency, error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (err.name === "AbortError")
|
if (err.name === "AbortError") {
|
||||||
|
log.error(`${url} timeout (aborted)`);
|
||||||
return { latency: null, error: "timeout" };
|
return { latency: null, error: "timeout" };
|
||||||
|
}
|
||||||
|
log.error(`${url} unreachable`);
|
||||||
return { latency: null, error: "unreachable" };
|
return { latency: null, error: "unreachable" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,7 +409,7 @@ function latencyClass(latency, status) {
|
|||||||
// --- Sparkline Renderer ------------------------------------------------------
|
// --- Sparkline Renderer ------------------------------------------------------
|
||||||
|
|
||||||
class SparklineRenderer {
|
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) {
|
static draw(canvas, history) {
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
@@ -332,7 +439,7 @@ class SparklineRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static _drawYAxis(ctx, w, h, m, ch) {
|
static _drawYAxis(ctx, w, h, m, ch) {
|
||||||
ctx.font = "9px monospace";
|
ctx.font = "300 12px monospace";
|
||||||
ctx.textAlign = "right";
|
ctx.textAlign = "right";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
for (const tick of CONFIG.yAxisTicks) {
|
for (const tick of CONFIG.yAxisTicks) {
|
||||||
@@ -354,7 +461,7 @@ class SparklineRenderer {
|
|||||||
for (const tick of CONFIG.xAxisTicks) {
|
for (const tick of CONFIG.xAxisTicks) {
|
||||||
const x = m.left + cw - (tick / CONFIG.historyDuration) * cw;
|
const x = m.left + cw - (tick / CONFIG.historyDuration) * cw;
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
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 -------------------------------------------------------------
|
// --- UI Renderer -------------------------------------------------------------
|
||||||
|
|
||||||
function hostRowHTML(host, index) {
|
function hostRowHTML(host, index, showPin = true) {
|
||||||
const pinColor = host.pinned
|
const pinColor = host.pinned
|
||||||
? "text-blue-400"
|
? "text-blue-500"
|
||||||
: "text-gray-600 hover:text-gray-400";
|
: "text-gray-600 hover:text-gray-400";
|
||||||
return `
|
const pinRotate = host.pinned ? "" : "rotate-45";
|
||||||
<div class="host-row bg-gray-800/50 rounded-lg p-4 border border-gray-700/50" data-index="${index}">
|
const pinBtn = showPin
|
||||||
<div class="flex items-center gap-4">
|
? `<button class="pin-btn flex-shrink-0 ${pinColor} transition-colors" data-pin="${index}" title="Pin to top">
|
||||||
<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">
|
||||||
<svg class="w-4 h-4" 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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
||||||
|
${pinBtn}
|
||||||
<div class="w-72 flex-shrink-0">
|
<div class="w-72 flex-shrink-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-3 h-3 rounded-full" style="background-color: ${latencyHex(null)}"></div>
|
<div class="w-3 h-3 rounded-full" style="background-color: ${latencyHex(null)}"></div>
|
||||||
@@ -449,11 +560,11 @@ function hostRowHTML(host, index) {
|
|||||||
</div>
|
</div>
|
||||||
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 mt-1 truncate block">${host.url}</a>
|
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 mt-1 truncate block">${host.url}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-32 flex-shrink-0 text-right">
|
<div class="w-64 flex-shrink-0 min-w-0 text-right">
|
||||||
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}">
|
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}">
|
||||||
<span class="text-gray-500">---</span>
|
<span class="text-gray-500">---</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-text text-xs text-gray-500 mt-1" data-host="${index}">waiting...</div>
|
<div class="status-text text-xs text-gray-500 mt-1 whitespace-nowrap" data-host="${index}">waiting...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
<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>
|
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
|
||||||
@@ -462,28 +573,56 @@ function hostRowHTML(host, index) {
|
|||||||
</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) {
|
function buildUI(state) {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="mx-auto px-[10%] py-8">
|
<div class="mx-auto px-[5%] py-8">
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h1 class="text-3xl font-bold text-white">NetWatch</h1>
|
<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>
|
||||||
<button id="pause-btn"
|
<div class="flex flex-col items-end gap-2">
|
||||||
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">
|
<button id="pause-btn"
|
||||||
<svg id="pause-icon" class="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
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">
|
||||||
<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>
|
<svg id="pause-icon" class="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>
|
||||||
<svg id="play-icon" class="w-8 h-8 text-green-400 hidden" fill="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<path d="M8 5v14l11-7z"/>
|
<svg id="play-icon" class="w-8 h-8 text-green-400 hidden" fill="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path d="M8 5v14l11-7z"/>
|
||||||
<span id="pause-text" class="text-lg font-semibold text-white">Pause</span>
|
</svg>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<p class="text-gray-400 text-sm">
|
<p class="text-gray-400 text-sm">
|
||||||
Real-time network latency monitor |
|
Real-time network latency monitor |
|
||||||
<span class="text-gray-500">Updates every ${CONFIG.updateInterval / 1000}s</span> |
|
<span id="update-interval-text" class="text-gray-500">Updates every ${humanDuration(CONFIG.updateInterval / 1000)}</span> |
|
||||||
<span class="text-gray-500">History: ${CONFIG.historyDuration}s</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>
|
<span id="status-indicator" class="text-green-400">Running</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-gray-500 text-xs font-mono mt-1">
|
<p class="text-gray-500 text-xs font-mono mt-1">
|
||||||
@@ -496,26 +635,34 @@ function buildUI(state) {
|
|||||||
<span class="text-gray-400">Reachable:</span> <span id="summary-reachable" class="text-white">--/--</span>
|
<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-600 mx-3">|</span>
|
||||||
<span class="text-gray-400">Now:</span>
|
<span class="text-gray-400">Now:</span>
|
||||||
<span id="summary-min" class="text-green-400">--</span>/<span id="summary-avg" class="text-yellow-400">--</span>/<span id="summary-max" class="text-red-400">--ms</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 class="text-gray-600 mx-3">|</span>
|
||||||
<span class="text-gray-400">${CONFIG.historyDuration}s:</span>
|
<span id="summary-duration-label" class="text-gray-400">${humanDuration(CONFIG.historyDuration)}:</span>
|
||||||
<span id="summary-hmin" class="text-green-400">--</span>/<span id="summary-hmax" class="text-red-400">--ms</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-600 mx-3">|</span>
|
||||||
<span class="text-gray-400">Checks:</span> <span id="summary-checks" class="text-gray-300">0</span>
|
<span class="text-gray-400">Checks:</span> <span id="summary-checks" class="text-gray-300">0</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="wan-hosts" class="space-y-4">
|
<div id="wan-hosts" class="space-y-4">
|
||||||
${state.wan.map((h, i) => hostRowHTML(h, i)).join("")}
|
${wanRowsHTML(state.wan)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-gray-500 text-xs uppercase tracking-wide mt-6 mb-3">Local Network</h2>
|
<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">
|
<div id="local-hosts" class="space-y-4">
|
||||||
${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("")}
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<footer class="mt-8 text-center text-gray-600 text-xs">
|
<footer class="mt-8 text-center text-gray-600 text-xs">
|
||||||
<p>Latency measured via HEAD requests | IPv4 only | CORS restrictions may affect some measurements</p>
|
<p>Latency measured via GET requests | IPv4 only | CORS restrictions may affect some measurements</p>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
<span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span><50ms
|
<span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span><50ms
|
||||||
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span><100ms
|
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span><100ms
|
||||||
@@ -524,6 +671,13 @@ function buildUI(state) {
|
|||||||
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>>500ms
|
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>>500ms
|
||||||
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
|
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
|
||||||
</p>
|
</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="cursor-pointer">
|
||||||
|
<input type="checkbox" id="debug-toggle" class="mr-1">
|
||||||
|
<span>Debug log</span>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -556,18 +710,31 @@ function updateHostRow(host, index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const avg = host.averageLatency();
|
const avg = host.averageLatency();
|
||||||
|
const med = host.medianLatency();
|
||||||
|
const min = host.minLatency();
|
||||||
|
const max = host.maxLatency();
|
||||||
if (host.status === "online" && avg !== null) {
|
if (host.status === "online" && avg !== null) {
|
||||||
statusEl.textContent = `avg: ${avg}ms`;
|
statusEl.innerHTML =
|
||||||
statusEl.className = `status-text text-xs mt-1 ${latencyClass(avg, "online")}`;
|
`<span class="text-gray-400">min </span><span class="${latencyClass(min, "online")}">${min}ms</span>` +
|
||||||
|
` <span class="text-gray-500">/</span> ` +
|
||||||
|
`<span class="text-gray-400">med </span><span class="${latencyClass(med, "online")}">${med}ms</span>` +
|
||||||
|
` <span class="text-gray-500">/</span> ` +
|
||||||
|
`<span class="text-gray-400">avg </span><span class="${latencyClass(avg, "online")}">${avg}ms</span>` +
|
||||||
|
` <span class="text-gray-500">/</span> ` +
|
||||||
|
`<span class="text-gray-400">max </span><span class="${latencyClass(max, "online")}">${max}ms</span>`;
|
||||||
|
statusEl.className = "status-text text-xs mt-1 whitespace-nowrap";
|
||||||
} else if (host.status === "offline") {
|
} else if (host.status === "offline") {
|
||||||
statusEl.textContent = "unreachable";
|
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") {
|
} else if (host.status === "error") {
|
||||||
statusEl.textContent = "timeout";
|
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 {
|
} else {
|
||||||
statusEl.textContent = "connecting...";
|
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);
|
SparklineRenderer.draw(canvas, host.history);
|
||||||
@@ -579,10 +746,17 @@ function updateSummary(state) {
|
|||||||
|
|
||||||
const reachableEl = document.getElementById("summary-reachable");
|
const reachableEl = document.getElementById("summary-reachable");
|
||||||
const minEl = document.getElementById("summary-min");
|
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 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 hminEl = document.getElementById("summary-hmin");
|
||||||
|
const hminUnitEl = document.getElementById("summary-hmin-unit");
|
||||||
const hmaxEl = document.getElementById("summary-hmax");
|
const hmaxEl = document.getElementById("summary-hmax");
|
||||||
|
const hmaxUnitEl = document.getElementById("summary-hmax-unit");
|
||||||
if (!reachableEl) return;
|
if (!reachableEl) return;
|
||||||
|
|
||||||
reachableEl.textContent = `${stats.reachable}/${stats.total}`;
|
reachableEl.textContent = `${stats.reachable}/${stats.total}`;
|
||||||
@@ -596,29 +770,45 @@ function updateSummary(state) {
|
|||||||
if (stats.min !== null) {
|
if (stats.min !== null) {
|
||||||
minEl.textContent = `${stats.min}`;
|
minEl.textContent = `${stats.min}`;
|
||||||
minEl.className = latencyClass(stats.min, "online");
|
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.textContent = `${stats.avg}`;
|
||||||
avgEl.className = latencyClass(stats.avg, "online");
|
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");
|
maxEl.className = latencyClass(stats.max, "online");
|
||||||
|
maxUnitEl.className = latencyClass(stats.max, "online");
|
||||||
} else {
|
} else {
|
||||||
minEl.textContent = "--";
|
minEl.textContent = "--";
|
||||||
minEl.className = "text-gray-500";
|
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.textContent = "--";
|
||||||
avgEl.className = "text-gray-500";
|
avgEl.className = "text-gray-500";
|
||||||
maxEl.textContent = "--ms";
|
avgUnitEl.className = "text-gray-500";
|
||||||
|
maxEl.textContent = "--";
|
||||||
maxEl.className = "text-gray-500";
|
maxEl.className = "text-gray-500";
|
||||||
|
maxUnitEl.className = "text-gray-500";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hstats.min !== null) {
|
if (hstats.min !== null) {
|
||||||
hminEl.textContent = `${hstats.min}`;
|
hminEl.textContent = `${hstats.min}`;
|
||||||
hminEl.className = latencyClass(hstats.min, "online");
|
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");
|
hmaxEl.className = latencyClass(hstats.max, "online");
|
||||||
|
hmaxUnitEl.className = latencyClass(hstats.max, "online");
|
||||||
} else {
|
} else {
|
||||||
hminEl.textContent = "--";
|
hminEl.textContent = "--";
|
||||||
hminEl.className = "text-gray-500";
|
hminEl.className = "text-gray-500";
|
||||||
hmaxEl.textContent = "--ms";
|
hminUnitEl.className = "text-gray-500";
|
||||||
|
hmaxEl.textContent = "--";
|
||||||
hmaxEl.className = "text-gray-500";
|
hmaxEl.className = "text-gray-500";
|
||||||
|
hmaxUnitEl.className = "text-gray-500";
|
||||||
}
|
}
|
||||||
|
|
||||||
const checksEl = document.getElementById("summary-checks");
|
const checksEl = document.getElementById("summary-checks");
|
||||||
@@ -633,16 +823,56 @@ function updateHealthBox(state) {
|
|||||||
const anyData = state.wan.some((h) => h.status !== "pending");
|
const anyData = state.wan.some((h) => h.status !== "pending");
|
||||||
if (!anyData) return;
|
if (!anyData) return;
|
||||||
|
|
||||||
const healthy = state.isHealthy();
|
const health = state.healthStatus();
|
||||||
el.textContent = healthy ? "HEALTHY" : "DEGRADED";
|
const labels = {
|
||||||
el.className = healthy
|
healthy: "HEALTHY",
|
||||||
? "text-green-400 font-bold"
|
slow: "SLOW",
|
||||||
: "text-red-400 font-bold";
|
degraded: "DEGRADED",
|
||||||
box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${
|
offline: "OFFLINE",
|
||||||
healthy
|
};
|
||||||
? "bg-green-900/20 border-green-700/50"
|
const textColors = {
|
||||||
: "bg-red-900/20 border-red-700/50"
|
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 `<div class="${cls}">${ts} ${lvl} ${entry.message}</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sorting -----------------------------------------------------------------
|
// --- Sorting -----------------------------------------------------------------
|
||||||
@@ -650,6 +880,7 @@ function updateHealthBox(state) {
|
|||||||
// Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts
|
// Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts
|
||||||
// by last latency ascending, with unreachable hosts at the bottom.
|
// by last latency ascending, with unreachable hosts at the bottom.
|
||||||
function sortAndRebuildWAN(state) {
|
function sortAndRebuildWAN(state) {
|
||||||
|
log.debug("WAN hosts re-sorted");
|
||||||
const pinned = state.wan
|
const pinned = state.wan
|
||||||
.filter((h) => h.pinned)
|
.filter((h) => h.pinned)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
@@ -663,12 +894,12 @@ function sortAndRebuildWAN(state) {
|
|||||||
state.wan = [...pinned, ...rest];
|
state.wan = [...pinned, ...rest];
|
||||||
|
|
||||||
const container = document.getElementById("wan-hosts");
|
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
|
// Re-index local hosts after WAN
|
||||||
const localContainer = document.getElementById("local-hosts");
|
const localContainer = document.getElementById("local-hosts");
|
||||||
localContainer.innerHTML = state.local
|
localContainer.innerHTML = state.local
|
||||||
.map((h, i) => hostRowHTML(h, state.wan.length + i))
|
.map((h, i) => hostRowHTML(h, state.wan.length + i, false))
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
// Resize canvases and redraw
|
// Resize canvases and redraw
|
||||||
@@ -676,18 +907,31 @@ function sortAndRebuildWAN(state) {
|
|||||||
document.querySelectorAll(".sparkline-canvas").forEach((canvas) => {
|
document.querySelectorAll(".sparkline-canvas").forEach((canvas) => {
|
||||||
SparklineRenderer.sizeCanvas(canvas);
|
SparklineRenderer.sizeCanvas(canvas);
|
||||||
});
|
});
|
||||||
state.allHosts.forEach((host, i) => {
|
if (state.paused) {
|
||||||
updateHostRow(host, i);
|
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 ---------------------------------------------------------------
|
// --- Main Loop ---------------------------------------------------------------
|
||||||
|
|
||||||
async function tick(state) {
|
async function tick(state, onOffline) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
if (state.paused) {
|
if (state.paused) {
|
||||||
|
log.debug("Tick skipped (paused)");
|
||||||
// No probes — just push a paused marker so the chart keeps scrolling
|
// No probes — just push a paused marker so the chart keeps scrolling
|
||||||
for (const host of state.allHosts) {
|
for (const host of state.allHosts) {
|
||||||
host.pushPaused(ts);
|
host.pushPaused(ts);
|
||||||
@@ -702,27 +946,152 @@ async function tick(state) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug(`Tick #${state.tickCount + 1} started`);
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
state.allHosts.map((h) => measureLatency(h.url)),
|
state.allHosts.map((h) => measureLatency(h.url)),
|
||||||
);
|
);
|
||||||
|
|
||||||
state.allHosts.forEach((host, i) => {
|
// User may have paused while awaiting results — discard them
|
||||||
host.pushSample(ts, results[i]);
|
if (state.paused) return;
|
||||||
updateHostRow(host, i);
|
|
||||||
});
|
|
||||||
|
|
||||||
state.tickCount++;
|
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);
|
sortAndRebuildWAN(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSummary(state);
|
updateSummary(state);
|
||||||
updateHealthBox(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 ----------------------------------------------------------
|
// --- 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 = `<span class="text-gray-500">${val}</span>`;
|
||||||
|
}
|
||||||
|
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) {
|
function togglePause(state) {
|
||||||
state.paused = !state.paused;
|
state.paused = !state.paused;
|
||||||
const pauseIcon = document.getElementById("pause-icon");
|
const pauseIcon = document.getElementById("pause-icon");
|
||||||
@@ -736,12 +1105,15 @@ function togglePause(state) {
|
|||||||
pauseText.textContent = "Resume";
|
pauseText.textContent = "Resume";
|
||||||
indicator.textContent = "Paused";
|
indicator.textContent = "Paused";
|
||||||
indicator.className = "text-yellow-400";
|
indicator.className = "text-yellow-400";
|
||||||
|
greyOutUI(state);
|
||||||
|
log.notice("Paused");
|
||||||
} else {
|
} else {
|
||||||
pauseIcon.classList.remove("hidden");
|
pauseIcon.classList.remove("hidden");
|
||||||
playIcon.classList.add("hidden");
|
playIcon.classList.add("hidden");
|
||||||
pauseText.textContent = "Pause";
|
pauseText.textContent = "Pause";
|
||||||
indicator.textContent = "Running";
|
indicator.textContent = "Running";
|
||||||
indicator.className = "text-green-400";
|
indicator.className = "text-green-400";
|
||||||
|
log.notice("Resumed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,18 +1130,34 @@ function handleResize(state) {
|
|||||||
// --- Bootstrap ---------------------------------------------------------------
|
// --- Bootstrap ---------------------------------------------------------------
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
log.info("NetWatch starting");
|
||||||
|
|
||||||
// Probe common gateway IPs to find the local router
|
// Probe common gateway IPs to find the local router
|
||||||
const gateway = await detectGateway();
|
const gateway = await detectGateway();
|
||||||
const localHosts = [LOCAL_CPE];
|
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);
|
const state = new AppState(localHosts);
|
||||||
buildUI(state);
|
buildUI(state);
|
||||||
|
log.info("UI built, starting tick loop");
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("pause-btn")
|
.getElementById("pause-btn")
|
||||||
.addEventListener("click", () => togglePause(state));
|
.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
|
// Pin button clicks — use event delegation so it survives DOM rebuilds
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
const btn = e.target.closest(".pin-btn");
|
const btn = e.target.closest(".pin-btn");
|
||||||
@@ -778,21 +1166,91 @@ async function init() {
|
|||||||
const host = state.wan[idx];
|
const host = state.wan[idx];
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
host.pinned = !host.pinned;
|
host.pinned = !host.pinned;
|
||||||
|
log.notice(`${host.pinned ? "Pinned" : "Unpinned"} ${host.name}`);
|
||||||
sortAndRebuildWAN(state);
|
sortAndRebuildWAN(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateClocks() {
|
function updateClocks() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const utc = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
const local = now.toLocaleString("sv-SE", { hour12: false });
|
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-local").textContent = "Local: " + local;
|
||||||
document.getElementById("clock-utc").textContent = "UTC: " + utc;
|
document.getElementById("clock-utc").textContent = "UTC: " + utc;
|
||||||
}
|
}
|
||||||
updateClocks();
|
updateClocks();
|
||||||
setInterval(updateClocks, 1000);
|
setInterval(updateClocks, 1000);
|
||||||
|
|
||||||
tick(state);
|
function doTick() {
|
||||||
setInterval(() => tick(state), CONFIG.updateInterval);
|
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));
|
window.addEventListener("resize", () => handleResize(state));
|
||||||
setTimeout(() => handleResize(state), 100);
|
setTimeout(() => handleResize(state), 100);
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import tailwindcss from "@tailwindcss/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({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
|
define: {
|
||||||
|
__COMMIT_HASH__: JSON.stringify(commitHash),
|
||||||
|
__COMMIT_FULL__: JSON.stringify(commitFull),
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
minify: "esbuild",
|
minify: "esbuild",
|
||||||
|
|||||||
Reference in New Issue
Block a user