The max-w-7xl (1280px) constraint left too much dead space between the host wells and the window edges. Remove it so the layout uses all available width.
677 lines
24 KiB
JavaScript
677 lines
24 KiB
JavaScript
import "./styles.css";
|
|
|
|
// --- Configuration -----------------------------------------------------------
|
|
|
|
// Timing, axis labels, and display constants. Latency above maxLatency is
|
|
// clamped to "unreachable". 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).
|
|
const CONFIG = Object.freeze({
|
|
updateInterval: 2000,
|
|
historyDuration: 300,
|
|
requestTimeout: 1500,
|
|
maxLatency: 1500,
|
|
graphMaxLatency: 1000,
|
|
yAxisTicks: [0, 250, 500, 750, 1000],
|
|
xAxisTicks: [0, 60, 120, 180, 240, 300],
|
|
canvasHeight: 80,
|
|
get maxHistoryPoints() {
|
|
return Math.ceil((this.historyDuration * 1000) / this.updateInterval);
|
|
},
|
|
});
|
|
|
|
// 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: "GitHub", url: "https://github.com" },
|
|
{ name: "B2", url: "https://api.backblazeb2.com" },
|
|
{
|
|
name: "S3 af-south-1 Cape Town (Africa)",
|
|
url: "https://s3.af-south-1.amazonaws.com",
|
|
},
|
|
{
|
|
name: "S3 eu-west-2 London (Europe)",
|
|
url: "https://s3.eu-west-2.amazonaws.com",
|
|
},
|
|
{
|
|
name: "S3 me-south-1 Bahrain (Middle East)",
|
|
url: "https://s3.me-south-1.amazonaws.com",
|
|
},
|
|
{
|
|
name: "S3 ap-northeast-1 Tokyo (Asia)",
|
|
url: "https://s3.ap-northeast-1.amazonaws.com",
|
|
},
|
|
{
|
|
name: "S3 ap-southeast-2 Sydney (Oceania)",
|
|
url: "https://s3.ap-southeast-2.amazonaws.com",
|
|
},
|
|
{
|
|
name: "S3 us-west-2 Oregon (North America)",
|
|
url: "https://s3.us-west-2.amazonaws.com",
|
|
},
|
|
{
|
|
name: "S3 sa-east-1 São Paulo (South America)",
|
|
url: "https://s3.sa-east-1.amazonaws.com",
|
|
},
|
|
// GCS locational endpoints — compare GCP routing vs AWS per-continent
|
|
{
|
|
name: "GCS us-central1 Iowa (North America)",
|
|
url: "https://storage.us-central1.rep.googleapis.com",
|
|
},
|
|
{
|
|
name: "GCS europe-west1 Belgium (Europe)",
|
|
url: "https://storage.europe-west1.rep.googleapis.com",
|
|
},
|
|
{
|
|
name: "GCS asia-southeast1 Singapore (Asia)",
|
|
url: "https://storage.asia-southeast1.rep.googleapis.com",
|
|
},
|
|
{
|
|
name: "GCS australia-southeast1 Sydney (Oceania)",
|
|
url: "https://storage.australia-southeast1.rep.googleapis.com",
|
|
},
|
|
];
|
|
|
|
// 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",
|
|
];
|
|
|
|
// --- 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(), 1500);
|
|
try {
|
|
await fetch(url, {
|
|
method: "HEAD",
|
|
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 {
|
|
// All candidates failed
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- App State ---------------------------------------------------------------
|
|
|
|
class HostState {
|
|
constructor(host) {
|
|
this.name = host.name;
|
|
this.url = host.url;
|
|
this.history = []; // { timestamp, latency, paused }
|
|
this.lastLatency = null;
|
|
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
averageLatency() {
|
|
const valid = this.history.filter((p) => p.latency !== null);
|
|
if (valid.length === 0) return null;
|
|
return Math.round(
|
|
valid.reduce((s, p) => s + p.latency, 0) / valid.length,
|
|
);
|
|
}
|
|
|
|
_trim() {
|
|
while (this.history.length > CONFIG.maxHistoryPoints)
|
|
this.history.shift();
|
|
}
|
|
}
|
|
|
|
class AppState {
|
|
constructor(localHosts) {
|
|
this.wan = WAN_HOSTS.map((h) => new HostState(h));
|
|
this.local = localHosts.map((h) => new HostState(h));
|
|
this.paused = false;
|
|
}
|
|
|
|
get allHosts() {
|
|
return [...this.wan, ...this.local];
|
|
}
|
|
|
|
/** WAN-only stats from latest sample (excludes local) */
|
|
wanStats() {
|
|
const reachable = this.wan.filter((h) => h.lastLatency !== null);
|
|
const latencies = reachable.map((h) => h.lastLatency);
|
|
const total = this.wan.length;
|
|
if (latencies.length === 0)
|
|
return { reachable: 0, total, min: null, max: null, avg: null };
|
|
return {
|
|
reachable: latencies.length,
|
|
total,
|
|
min: Math.min(...latencies),
|
|
max: Math.max(...latencies),
|
|
avg: Math.round(
|
|
latencies.reduce((a, b) => a + b, 0) / latencies.length,
|
|
),
|
|
};
|
|
}
|
|
|
|
/** Overall health: true = healthy (more than half WAN reachable) */
|
|
isHealthy() {
|
|
const reachable = this.wan.filter((h) => h.lastLatency !== null).length;
|
|
return reachable > this.wan.length / 2;
|
|
}
|
|
}
|
|
|
|
// --- Latency Measurement -----------------------------------------------------
|
|
|
|
async function measureLatency(url) {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(
|
|
() => controller.abort(),
|
|
CONFIG.requestTimeout,
|
|
);
|
|
|
|
const targetUrl = new URL(url);
|
|
targetUrl.searchParams.set("_cb", Date.now().toString());
|
|
|
|
const start = performance.now();
|
|
|
|
try {
|
|
await fetch(targetUrl.toString(), {
|
|
method: "HEAD",
|
|
mode: "no-cors",
|
|
cache: "no-store",
|
|
signal: controller.signal,
|
|
});
|
|
const latency = Math.round(performance.now() - start);
|
|
clearTimeout(timeoutId);
|
|
if (latency > CONFIG.maxLatency)
|
|
return { latency: null, error: "timeout" };
|
|
return { latency, error: null };
|
|
} catch (err) {
|
|
clearTimeout(timeoutId);
|
|
if (err.name === "AbortError")
|
|
return { latency: null, error: "timeout" };
|
|
return { latency: null, error: "unreachable" };
|
|
}
|
|
}
|
|
|
|
// --- Color Helpers -----------------------------------------------------------
|
|
|
|
function latencyHex(latency) {
|
|
if (latency === null) return "#6b7280";
|
|
if (latency < 50) return "#22c55e";
|
|
if (latency < 100) return "#84cc16";
|
|
if (latency < 200) return "#eab308";
|
|
if (latency < 500) return "#f97316";
|
|
return "#ef4444";
|
|
}
|
|
|
|
function latencyClass(latency, status) {
|
|
if (status === "offline" || status === "error" || latency === null)
|
|
return "text-gray-500";
|
|
if (latency < 50) return "text-green-500";
|
|
if (latency < 100) return "text-lime-500";
|
|
if (latency < 200) return "text-yellow-500";
|
|
if (latency < 500) return "text-orange-500";
|
|
return "text-red-500";
|
|
}
|
|
|
|
// --- Sparkline Renderer ------------------------------------------------------
|
|
|
|
class SparklineRenderer {
|
|
static MARGIN = { left: 35, right: 10, top: 5, bottom: 18 };
|
|
|
|
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, h, m, ch);
|
|
SparklineRenderer._drawXAxis(ctx, w, 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, h, m, ch) {
|
|
ctx.font = "9px 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, w, 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(`-${tick}s`, 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 -------------------------------------------------------------
|
|
|
|
function hostRowHTML(host, index) {
|
|
return `
|
|
<div class="host-row bg-gray-800/50 rounded-lg p-4 border border-gray-700/50" data-index="${index}">
|
|
<div class="flex items-center gap-4">
|
|
<div class="w-48 flex-shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-3 h-3 rounded-full" style="background-color: ${latencyHex(null)}"></div>
|
|
<span class="font-medium text-white truncate">${host.name}</span>
|
|
</div>
|
|
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 mt-1 truncate block">${host.url}</a>
|
|
</div>
|
|
<div class="w-32 flex-shrink-0 text-right">
|
|
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}">
|
|
<span class="text-gray-500">---</span>
|
|
</div>
|
|
<div class="status-text text-xs text-gray-500 mt-1" 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 buildUI(state) {
|
|
const app = document.getElementById("app");
|
|
app.innerHTML = `
|
|
<div class="mx-auto px-4 py-8">
|
|
<header class="mb-8">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h1 class="text-3xl font-bold text-white">NetWatch</h1>
|
|
<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>
|
|
<p class="text-gray-400 text-sm">
|
|
Real-time network latency monitor |
|
|
<span class="text-gray-500">Updates every ${CONFIG.updateInterval / 1000}s</span> |
|
|
<span class="text-gray-500">History: ${CONFIG.historyDuration}s</span> |
|
|
<span id="status-indicator" class="text-green-400">Running</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">Min:</span> <span id="summary-min" class="text-green-400">--ms</span>
|
|
<span class="text-gray-600 mx-3">|</span>
|
|
<span class="text-gray-400">Max:</span> <span id="summary-max" class="text-red-400">--ms</span>
|
|
<span class="text-gray-600 mx-3">|</span>
|
|
<span class="text-gray-400">Avg:</span> <span id="summary-avg" class="text-yellow-400">--ms</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="wan-hosts" class="space-y-4">
|
|
${state.wan.map((h, i) => hostRowHTML(h, i)).join("")}
|
|
</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)).join("")}
|
|
</div>
|
|
|
|
<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 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-lime-500 mr-1 ml-3 align-middle"></span><100ms
|
|
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500 mr-1 ml-3 align-middle"></span><200ms
|
|
<span class="inline-block w-3 h-3 rounded-full bg-orange-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
|
|
</p>
|
|
</footer>
|
|
</div>`;
|
|
|
|
requestAnimationFrame(() => {
|
|
document
|
|
.querySelectorAll(".sparkline-canvas")
|
|
.forEach((c) => SparklineRenderer.sizeCanvas(c));
|
|
});
|
|
}
|
|
|
|
// --- UI Updaters -------------------------------------------------------------
|
|
|
|
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 avg = host.averageLatency();
|
|
if (host.status === "online" && avg !== null) {
|
|
statusEl.textContent = `avg: ${avg}ms`;
|
|
statusEl.className = `status-text text-xs mt-1 ${latencyClass(avg, "online")}`;
|
|
} else if (host.status === "offline") {
|
|
statusEl.textContent = "unreachable";
|
|
statusEl.className = "status-text text-xs text-red-400 mt-1";
|
|
} else if (host.status === "error") {
|
|
statusEl.textContent = "timeout";
|
|
statusEl.className = "status-text text-xs text-orange-400 mt-1";
|
|
} else {
|
|
statusEl.textContent = "connecting...";
|
|
statusEl.className = "status-text text-xs text-gray-500 mt-1";
|
|
}
|
|
|
|
SparklineRenderer.draw(canvas, host.history);
|
|
}
|
|
|
|
function updateSummary(state) {
|
|
const stats = state.wanStats();
|
|
|
|
const reachableEl = document.getElementById("summary-reachable");
|
|
const minEl = document.getElementById("summary-min");
|
|
const maxEl = document.getElementById("summary-max");
|
|
const avgEl = document.getElementById("summary-avg");
|
|
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}ms`;
|
|
minEl.className = latencyClass(stats.min, "online");
|
|
maxEl.textContent = `${stats.max}ms`;
|
|
maxEl.className = latencyClass(stats.max, "online");
|
|
avgEl.textContent = `${stats.avg}ms`;
|
|
avgEl.className = latencyClass(stats.avg, "online");
|
|
} else {
|
|
for (const el of [minEl, maxEl, avgEl]) {
|
|
el.textContent = "--ms";
|
|
el.className = "text-gray-500";
|
|
}
|
|
}
|
|
}
|
|
|
|
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 healthy = state.isHealthy();
|
|
el.textContent = healthy ? "HEALTHY" : "DEGRADED";
|
|
el.className = healthy
|
|
? "text-green-400 font-bold"
|
|
: "text-red-400 font-bold";
|
|
box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${
|
|
healthy
|
|
? "bg-green-900/20 border-green-700/50"
|
|
: "bg-red-900/20 border-red-700/50"
|
|
}`;
|
|
}
|
|
|
|
// --- Main Loop ---------------------------------------------------------------
|
|
|
|
async function tick(state) {
|
|
const ts = Date.now();
|
|
|
|
if (state.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;
|
|
}
|
|
|
|
const results = await Promise.all(
|
|
state.allHosts.map((h) => measureLatency(h.url)),
|
|
);
|
|
|
|
state.allHosts.forEach((host, i) => {
|
|
host.pushSample(ts, results[i]);
|
|
updateHostRow(host, i);
|
|
});
|
|
|
|
updateSummary(state);
|
|
updateHealthBox(state);
|
|
}
|
|
|
|
// --- Pause / Resume ----------------------------------------------------------
|
|
|
|
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";
|
|
} else {
|
|
pauseIcon.classList.remove("hidden");
|
|
playIcon.classList.add("hidden");
|
|
pauseText.textContent = "Pause";
|
|
indicator.textContent = "Running";
|
|
indicator.className = "text-green-400";
|
|
}
|
|
}
|
|
|
|
// --- 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() {
|
|
// Probe common gateway IPs to find the local router
|
|
const gateway = await detectGateway();
|
|
const localHosts = [LOCAL_CPE];
|
|
if (gateway) localHosts.push(gateway);
|
|
|
|
const state = new AppState(localHosts);
|
|
buildUI(state);
|
|
|
|
document
|
|
.getElementById("pause-btn")
|
|
.addEventListener("click", () => togglePause(state));
|
|
|
|
tick(state);
|
|
setInterval(() => tick(state), CONFIG.updateInterval);
|
|
|
|
window.addEventListener("resize", () => handleResize(state));
|
|
setTimeout(() => handleResize(state), 100);
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|