Something went wrong. Try again.
A lightweight home network monitor with a live dashboard for latency, packet loss, jitter, DNS & bandwidth. Runs on Pi, Docker, or any mach…
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536const $ = (s) => document.querySelector(s);let chartInstances = {};let lastUpdateTime = null;let isLoading = false;let currentMinutes = 60;let prevSummary = null;let prevStatus = null;const REFRESH_MS = 15000;
function getChartHeight() { if (window.innerWidth >= 1024) { const grid = document.getElementById('chartGrid'); if (grid && grid.offsetHeight > 0) { // 2 rows with 12px gap; each card has ~52px overhead (padding + header) return Math.max(80, Math.floor((grid.offsetHeight - 12) / 2) - 52); } } return window.innerWidth < 640 ? 160 : 185;}
// ── Live "Xs ago" ticker ──────────────────────────────────────────────setInterval(() => { if (!lastUpdateTime) return; const sec = Math.floor((Date.now() - lastUpdateTime) / 1000); let label; if (sec < 5) label = "just now"; else if (sec < 60) label = sec + "s ago"; else if (sec < 3600) label = Math.floor(sec / 60) + "m ago"; else label = Math.floor(sec / 3600) + "h ago"; const el = $("#lastUpdated"); if (el && !el.classList.contains("refreshing")) el.textContent = label;}, 1000);
// ── SVG icons ─────────────────────────────────────────────────────────const SVG = { clock: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`, arrowDown: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`, arrowUp: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`, activity: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,};
// ── Data loading ──────────────────────────────────────────────────────async function loadData(forceRecreate = false) { if (isLoading) return; isLoading = true; const lu = $("#lastUpdated"); if (lu) { lu.classList.add("refreshing"); lu.textContent = "Refreshing…"; }
const [data, cfg] = await Promise.all([ fetch(`/api/data?minutes=${currentMinutes}`).then(r => r.json()).catch(() => null), fetch("/api/config").then(r => r.json()).catch(() => null), ]);
isLoading = false; if (lu) lu.classList.remove("refreshing");
if (!data) { if (lu) lu.textContent = "Error"; return; }
lastUpdateTime = Date.now(); checkThresholds(data.summary, prevSummary);
// Filter to current config only — gateway is excluded from the table // (it's a local router probe, not an internet connectivity target) if (cfg) { const allowedPing = new Set(cfg.ping_targets.map(h => h.toLowerCase())); data.targets = (data.targets || []).filter(t => allowedPing.has(t.host.toLowerCase())); const allowedDns = new Set(cfg.dns_targets.map(h => h.toLowerCase())); data.dns = (data.dns || []).filter(d => allowedDns.has(d.host.toLowerCase())); }
renderHeader(data, cfg); renderSummary(data.summary, data.history); renderCharts(data.history, data.summary, forceRecreate); renderHealth(data.summary); renderTargets(data.targets); renderDNS(data.dns); renderConnection(data.history); renderQuickStats(data.summary, data.history); prevSummary = data.summary;}
// ── Header ────────────────────────────────────────────────────────────function renderHeader(data, cfg) { const targetCount = cfg ? cfg.ping_targets.length : (data.targets || []).length; const intervalLabel = cfg ? ` · ping ${cfg.ping_interval_s}s` : ""; $("#targetCount").textContent = targetCount; $("#networkLabel").textContent = (data.network_id || "unknown") + intervalLabel;
const loss = data.summary.packet_loss; const badge = $("#statusBadge"); if (loss > 5) { badge.className = "status-badge offline"; badge.innerHTML = '<span class="status-dot"></span>Offline'; } else if (loss > 1) { badge.className = "status-badge degraded"; badge.innerHTML = '<span class="status-dot"></span>Degraded'; } else { badge.className = "status-badge online"; badge.innerHTML = '<span class="status-dot"></span>Online'; }}
// ── Summary cards ─────────────────────────────────────────────────────function renderSummary(s, history) { const lossColor = s.packet_loss > 1 ? "red" : "green"; const last = (history || []).slice(-20); const sc = { blue: "#3b82f6", green: "#10b981", purple: "#8b5cf6", red: "#ef4444" }; const items = [ { label: "Avg Latency", value: s.latency_avg + " ms", icon: SVG.clock, color: "blue", sub: "p95 " + s.latency_p95 + " ms", spark: sparklineSVG(last.map(h => h.latency), sc.blue) }, { label: "Download", value: s.download_avg + " Mbps", icon: SVG.arrowDown, color: "green", sub: "avg throughput", spark: sparklineSVG(last.map(h => h.download), sc.green) }, { label: "Upload", value: s.upload_avg + " Mbps", icon: SVG.arrowUp, color: "purple", sub: "avg throughput", spark: sparklineSVG(last.map(h => h.upload), sc.purple) }, { label: "Packet Loss", value: s.packet_loss + "%", icon: SVG.activity, color: lossColor, sub: s.packet_loss < 1 ? "healthy" : "elevated", spark: sparklineSVG(last.map(h => h.loss), sc[lossColor]) }, ]; $("#summaryCards").innerHTML = items.map(m => `<div class="card metric-card" data-color="${m.color}"> <div class="flex items-center justify-between"> <div class="metric-label">${m.label}</div> <div class="metric-icon ${m.color}">${m.icon}</div> </div> <div class="metric-value">${m.value}</div> <div class="metric-sub">${m.sub}</div> ${m.spark} </div>` ).join(""); // Flash each card to signal new data document.querySelectorAll("#summaryCards .card").forEach(c => { c.classList.remove("flash-update"); void c.offsetWidth; c.classList.add("flash-update"); });}
// ── Charts ────────────────────────────────────────────────────────────function renderCharts(history, summary, forceRecreate = false) { if (!history || history.length === 0) return;
const times = history.map(h => h.time); const isSmall = window.innerWidth < 640; const chartH = getChartHeight(); const tickCount = Math.min(times.length, isSmall ? 4 : 6); const dark = document.body.classList.contains("dark-mode"); const labelStyle = { colors: "var(--muted)", fontSize: isSmall ? "9px" : "10px" };
$("#latencyAvgLabel").textContent = "avg " + summary.latency_avg + " ms"; $("#speedAvgLabel").textContent = "↓" + summary.download_avg + " ↑" + summary.upload_avg + " Mbps"; $("#lossAvgLabel").textContent = "loss " + summary.packet_loss + "% · jitter " + summary.jitter_avg + " ms"; $("#dnsAvgLabel").textContent = "avg " + summary.dns_avg + " ms";
// Smooth update when charts already exist (no flicker) if (!forceRecreate && chartInstances.latency) { const xAxis = { categories: times, tickAmount: tickCount, labels: { style: labelStyle } }; chartInstances.latency.updateOptions({ xaxis: xAxis, series: [{ name: "Latency (ms)", data: history.map(h => h.latency) }] }, false, true); chartInstances.speed.updateOptions({ xaxis: xAxis, series: [{ name: "Download", data: history.map(h => h.download) }, { name: "Upload", data: history.map(h => h.upload) }] }, false, true); chartInstances.loss.updateOptions({ xaxis: xAxis, series: [{ name: "Loss (%)", data: history.map(h => h.loss) }, { name: "Jitter (ms)", data: history.map(h => h.jitter) }] }, false, true); chartInstances.dns.updateOptions({ xaxis: xAxis, series: [{ name: "DNS (ms)", data: history.map(h => h.dns) }] }, false, true); return; }
// Full create (first load or after theme change) Object.values(chartInstances).forEach(c => c.destroy()); chartInstances = {};
const base = { chart: { type: "area", background: "transparent", fontFamily: "'Inter',sans-serif", toolbar: { show: false }, height: chartH, animations: { speed: 500 }, zoom: { enabled: false } }, stroke: { curve: "smooth", width: 2 }, markers: { size: times.length <= 5 ? 4 : 0, hover: { size: 5 } }, grid: { borderColor: "var(--border)", xaxis: { lines: { show: false } }, yaxis: { lines: { show: true } }, padding: { top: -8, right: 8, bottom: 0, left: 8 } }, xaxis: { categories: times, tickAmount: tickCount, labels: { style: labelStyle, rotate: 0, hideOverlappingLabels: true, maxHeight: 30 }, axisBorder: { show: false }, axisTicks: { show: false }, }, yaxis: { labels: { style: labelStyle }, forceNiceScale: true }, legend: { position: "top", fontSize: "10px", labels: { colors: "var(--subtle)" }, markers: { width: 6, height: 6, radius: 2 }, itemMargin: { horizontal: 8 } }, tooltip: { theme: dark ? "dark" : "light", style: { fontSize: "11px" }, x: { show: true } }, dataLabels: { enabled: false }, }; const fillArea = (o = 0.12) => ({ type: "gradient", gradient: { shadeIntensity: 0, opacityFrom: o, opacityTo: 0.01, stops: [0, 95] } });
chartInstances.latency = new ApexCharts($("#latencyChart"), { ...base, series: [{ name: "Latency (ms)", data: history.map(h => h.latency) }], colors: ["#3b82f6"], fill: fillArea(), yaxis: { ...base.yaxis, min: 0 } }); chartInstances.latency.render();
chartInstances.speed = new ApexCharts($("#speedChart"), { ...base, series: [{ name: "Download", data: history.map(h => h.download) }, { name: "Upload", data: history.map(h => h.upload) }], colors: ["#10b981", "#8b5cf6"], fill: fillArea(0.08) }); chartInstances.speed.render();
chartInstances.loss = new ApexCharts($("#lossChart"), { ...base, series: [{ name: "Loss (%)", data: history.map(h => h.loss) }, { name: "Jitter (ms)", data: history.map(h => h.jitter) }], colors: ["#ef4444", "#f59e0b"], fill: fillArea(0.1), yaxis: [ { forceNiceScale: true, min: 0, title: { text: "Loss %", style: { fontSize: "10px", color: "var(--muted)" } }, labels: { style: { colors: "var(--muted)", fontSize: "10px" } } }, { opposite: true, forceNiceScale: true, min: 0, title: { text: "Jitter ms", style: { fontSize: "10px", color: "var(--muted)" } }, labels: { style: { colors: "var(--muted)", fontSize: "10px" } } }, ], }); chartInstances.loss.render();
chartInstances.dns = new ApexCharts($("#dnsChart"), { ...base, series: [{ name: "DNS (ms)", data: history.map(h => h.dns) }], colors: ["#8b5cf6"], fill: fillArea(), yaxis: { ...base.yaxis, min: 0 } }); chartInstances.dns.render();}
// ── Health ring ───────────────────────────────────────────────────────function renderHealth(s) { let score = 100; if (s.latency_avg > 50) score -= 20; else if (s.latency_avg > 30) score -= 10; else if (s.latency_avg > 20) score -= 5; if (s.packet_loss > 5) score -= 30; else if (s.packet_loss > 1) score -= 15; else if (s.packet_loss > 0.5) score -= 5; if (s.download_avg < 25) score -= 20; else if (s.download_avg < 50) score -= 10; if (s.jitter_avg > 10) score -= 10; else if (s.jitter_avg > 5) score -= 5; score = Math.max(0, Math.min(100, score));
const circ = 2 * Math.PI * 42; const arc = $("#healthArc"); const color = score >= 80 ? "var(--green)" : score >= 50 ? "var(--yellow)" : "var(--red)"; arc.style.stroke = color; requestAnimationFrame(() => { arc.style.strokeDashoffset = circ - (score / 100) * circ; }); $("#healthNum").textContent = score; $("#healthNum").style.color = color;
const rate = (val, good, warn) => val <= good ? { text: "Good", color: "var(--green)" } : val <= warn ? { text: "Fair", color: "var(--yellow)" } : { text: "Poor", color: "var(--red)" }; const lat = rate(s.latency_avg, 20, 50); $("#hbLatency").textContent = lat.text; $("#hbLatency").style.color = lat.color; const loss = rate(s.packet_loss, 0.5, 2); $("#hbLoss").textContent = loss.text; $("#hbLoss").style.color = loss.color; const spd = s.download_avg >= 50 ? { text: "Good", color: "var(--green)" } : s.download_avg >= 25 ? { text: "Fair", color: "var(--yellow)" } : { text: "Poor", color: "var(--red)" }; $("#hbSpeed").textContent = spd.text; $("#hbSpeed").style.color = spd.color;}
// ── Tables ────────────────────────────────────────────────────────────function renderTargets(targets) { if (!targets) return; $("#targetBody").innerHTML = targets.map(t => `<tr> <td> <div class="host-cell"><span class="inline-dot ${t.status === 'up' ? 'up' : 'down'}"></span><span>${t.host}</span></div> <div class="ip-cell">${t.ip}</div> </td> <td class="font-semibold">${t.latency} ms</td> <td>${t.loss}%</td> </tr>` ).join("");}
function renderDNS(dns) { if (!dns) return; $("#dnsBody").innerHTML = dns.map(d => `<tr> <td class="font-medium">${d.host}</td> <td class="font-semibold">${d.time_ms} ms</td> </tr>` ).join("");}
function renderConnection(history) { const card = document.getElementById('connCard'); if (!card || !history || history.length === 0) return; const latest = history[history.length - 1]; const type = latest.conn_type;
if (!type) { card.style.display = 'none'; return; } card.style.display = '';
const wifiDetails = document.getElementById('conn-wifi-details'); const ethDetails = document.getElementById('conn-eth-details'); const wifiIcon = document.getElementById('conn-icon-wifi'); const ethIcon = document.getElementById('conn-icon-eth'); const title = document.getElementById('conn-section-title');
if (type === 'wifi') { title.textContent = 'WiFi Signal'; wifiIcon.style.display = ''; ethIcon.style.display = 'none'; wifiDetails.style.display = ''; ethDetails.style.display = 'none';
const rssi = latest.conn_rssi; const snr = latest.conn_snr; const band = latest.conn_band; const ch = latest.conn_channel; const rate = latest.conn_link_rate;
const pct = Math.max(0, Math.min(100, ((rssi + 90) / 60) * 100)); let quality, color; if (rssi >= -50) { quality = 'Excellent'; color = 'var(--green)'; } else if (rssi >= -65) { quality = 'Good'; color = 'var(--green)'; } else if (rssi >= -75) { quality = 'Fair'; color = 'var(--yellow)'; } else { quality = 'Poor'; color = 'var(--red)'; }
const rssiEl = document.getElementById('wifi-rssi'); rssiEl.textContent = rssi + ' dBm'; rssiEl.style.color = color; document.getElementById('wifi-snr').textContent = snr ? snr + ' dB' : '—'; document.getElementById('wifi-link-rate').textContent = rate ? rate + ' Mbps' : '—'; document.getElementById('wifi-band-ch').textContent = [band, ch ? 'ch. ' + ch : ''].filter(Boolean).join(' · ') || '—';
const bar = document.getElementById('wifi-signal-bar'); if (bar) { bar.style.width = pct + '%'; bar.style.background = color; } const ql = document.getElementById('wifi-quality-label'); if (ql) { ql.textContent = quality; ql.style.color = color; }
} else if (type === 'ethernet') { title.textContent = 'Ethernet'; wifiIcon.style.display = 'none'; ethIcon.style.display = ''; wifiDetails.style.display = 'none'; ethDetails.style.display = '';
const rate = latest.conn_link_rate; const duplex = latest.conn_duplex; const speedEl = document.getElementById('eth-link-speed'); speedEl.textContent = rate ? (rate >= 1000 ? (rate / 1000) + ' Gbps' : rate + ' Mbps') : '—'; speedEl.style.color = rate >= 1000 ? 'var(--green)' : 'var(--fg)'; document.getElementById('eth-duplex').textContent = duplex ? duplex.charAt(0).toUpperCase() + duplex.slice(1) + ' duplex' : '—'; }}
function renderQuickStats(s, history) { if (!history || history.length === 0) return; const peakDl = Math.max(...history.map(h => h.download)); $("#qs-uptime").textContent = s.uptime_24h + "%"; $("#qs-outages").textContent = s.outages_24h; $("#qs-peak-dl").textContent = peakDl + " Mbps"; $("#qs-min-lat").textContent = (s.latency_min || Math.min(...history.map(h => h.latency))) + " ms"; $("#qs-max-lat").textContent = (s.latency_max || Math.max(...history.map(h => h.latency))) + " ms"; $("#qs-jitter").textContent = s.jitter_avg + " ms";}
// ── Sparkline helper ──────────────────────────────────────────────────function sparklineSVG(values, color) { if (!values || values.length < 2) return ''; const clean = values.filter(v => v != null && !isNaN(v) && v >= 0); if (clean.length < 2) return ''; const w = 72, h = 32; const min = Math.min(...clean), max = Math.max(...clean); const range = max - min || 1; const pts = clean.map((v, i) => { const x = (i / (clean.length - 1)) * w; const y = (h - 2) - ((v - min) / range) * (h - 6); return x.toFixed(1) + ',' + y.toFixed(1); }).join(' '); return '<svg width="' + w + '" height="' + h + '" style="position:absolute;bottom:6px;right:10px;pointer-events:none" xmlns="http://www.w3.org/2000/svg">' + '<polyline points="' + pts + '" fill="none" stroke="' + color + '" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.35"/></svg>';}
// ── Toast notifications ────────────────────────────────────────────────const activeToasts = new Set();
function showToast(id, title, msg, type, duration) { duration = duration || 5000; if (activeToasts.has(id)) return; activeToasts.add(id); const container = document.getElementById('toastContainer'); if (!container) return; const icons = { warn: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>', error: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>', ok: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', }; const el = document.createElement('div'); el.className = 'toast ' + type; el.innerHTML = '<span class="toast-icon">' + (icons[type] || icons.warn) + '</span>' + '<div class="toast-body"><div class="toast-title">' + title + '</div>' + (msg ? '<div class="toast-msg">' + msg + '</div>' : '') + '</div>'; container.appendChild(el); const dismiss = () => { el.classList.add('dismiss'); setTimeout(() => { el.remove(); activeToasts.delete(id); }, 250); }; el.addEventListener('click', dismiss); setTimeout(dismiss, duration);}
// ── Threshold checks ──────────────────────────────────────────────────function checkThresholds(s, prev) { const curr = s.packet_loss > 5 ? 'offline' : s.packet_loss > 1 ? 'degraded' : 'online'; if (prevStatus !== null && prevStatus !== curr) { if (curr === 'offline') showToast('st-offline', 'Connection Offline', 'Packet loss at ' + s.packet_loss + '%', 'error', 8000); else if (curr === 'degraded') showToast('st-degraded', 'Connection Degraded', 'Packet loss at ' + s.packet_loss + '%', 'warn', 7000); else showToast('st-ok', 'Connection Restored', 'All metrics back to normal', 'ok', 5000); } prevStatus = curr; if (prev && s.latency_avg > 150 && prev.latency_avg <= 150) showToast('lat-spike', 'Latency Spike', s.latency_avg + 'ms avg (was ' + prev.latency_avg + 'ms)', 'warn', 6000);}
// ── Duration picker ───────────────────────────────────────────────────const durationBtn = $("#durationBtn");const durationPanel = $("#durationPanel");const durationLabel = $("#durationLabel");
function setDuration(minutes, label, reload = true) { currentMinutes = minutes; durationLabel.textContent = label; document.querySelectorAll(".dur-chip").forEach(c => { c.classList.toggle("active", parseInt(c.dataset.minutes) === minutes); }); closeDurationPanel(); if (reload) loadData(true);}
function closeDurationPanel() { durationPanel.classList.remove("open"); durationBtn.classList.remove("open");}
durationBtn.addEventListener("click", e => { e.stopPropagation(); const isOpen = durationPanel.classList.toggle("open"); durationBtn.classList.toggle("open", isOpen);});
document.querySelectorAll(".dur-chip").forEach(chip => { chip.addEventListener("click", () => { setDuration(parseInt(chip.dataset.minutes), chip.dataset.label); });});
$("#durApply").addEventListener("click", () => { const n = Math.max(1, parseInt($("#durNum").value) || 1); const unit = parseInt($("#durUnit").value); const mins = n * unit; const unitLabel = unit === 1 ? "m" : unit === 60 ? "h" : "d"; setDuration(mins, n + unitLabel);});
$("#durNum").addEventListener("keydown", e => { if (e.key === "Enter") $("#durApply").click(); });
document.addEventListener("click", e => { if (!$("#durationWrapper").contains(e.target)) closeDurationPanel();});
// ── Theme toggle ──────────────────────────────────────────────────────const toggle = $("#themeToggle");function applyTheme(dark) { document.body.classList.toggle("dark-mode", dark); $("#iconMoon").style.display = dark ? "none" : ""; $("#iconSun").style.display = dark ? "" : "none";}applyTheme(localStorage.getItem("theme") === "dark");toggle.addEventListener("click", () => { const dark = document.body.classList.toggle("dark-mode"); applyTheme(dark); localStorage.setItem("theme", dark ? "dark" : "light"); loadData(true); // recreate charts so tooltip theme updates});
// ── Settings modal ────────────────────────────────────────────────────const backdrop = $("#modalBackdrop");
function openSettings() { backdrop.classList.add("open"); loadConfig(); }function closeSettings() { backdrop.classList.remove("open"); $("#saveStatus").textContent = ""; }
async function loadConfig() { try { const cfg = await fetch("/api/config").then(r => r.json()); $("#cfgPingTargets").value = (cfg.ping_targets || []).join("\n"); $("#cfgDnsTargets").value = (cfg.dns_targets || []).join("\n"); $("#cfgPingInterval").value = cfg.ping_interval_s || 60; $("#cfgSpeedInterval").value = cfg.speed_interval_m || 30; $("#cfgPingCount").value = cfg.ping_count || 5; } catch { showStatus("Failed to load config", false); }}
async function saveConfig() { const saveBtn = $("#saveBtn"); saveBtn.disabled = true; const pingTargets = $("#cfgPingTargets").value.split("\n").map(s => s.trim()).filter(Boolean); const dnsTargets = $("#cfgDnsTargets").value.split("\n").map(s => s.trim()).filter(Boolean); const pingIntervalS = parseInt($("#cfgPingInterval").value, 10); const speedIntervalM = parseInt($("#cfgSpeedInterval").value, 10); const pingCount = parseInt($("#cfgPingCount").value, 10);
if (!pingTargets.length) { showStatus("Probe hosts cannot be empty", false); saveBtn.disabled = false; return; } if (pingIntervalS < 10) { showStatus("Ping interval must be ≥ 10 s", false); saveBtn.disabled = false; return; } if (speedIntervalM < 5) { showStatus("Speed interval must be ≥ 5 min", false); saveBtn.disabled = false; return; } if (pingCount < 1 || pingCount > 20) { showStatus("Ping count must be 1–20", false); saveBtn.disabled = false; return; }
try { const res = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ping_targets: pingTargets, dns_targets: dnsTargets, ping_interval_s: pingIntervalS, speed_interval_m: speedIntervalM, ping_count: pingCount }), }); const body = await res.json(); if (res.ok) { showStatus("Saved!", true); setTimeout(closeSettings, 900); } else showStatus(body.error || "Save failed", false); } catch { showStatus("Network error", false); }
saveBtn.disabled = false;}
function showStatus(msg, ok) { const el = $("#saveStatus"); el.textContent = msg; el.className = "save-status " + (ok ? "ok" : "err");}
$("#settingsBtn").addEventListener("click", openSettings);$("#modalClose").addEventListener("click", closeSettings);$("#modalCancel").addEventListener("click", closeSettings);$("#saveBtn").addEventListener("click", saveConfig);backdrop.addEventListener("click", e => { if (e.target === backdrop) closeSettings(); });document.addEventListener("keydown", e => { if (e.key === "Escape") closeSettings(); });
// ── Accordion ─────────────────────────────────────────────────────────function initAccordions() { document.querySelectorAll('.accordion-toggle').forEach(toggle => { const card = toggle.closest('[data-section]'); if (!card) return; const key = 'acc-' + card.dataset.section; if (localStorage.getItem(key) === '1') card.classList.add('s-collapsed'); toggle.addEventListener('click', () => { const collapsed = card.classList.toggle('s-collapsed'); localStorage.setItem(key, collapsed ? '1' : '0'); }); });}
// ── Init ──────────────────────────────────────────────────────────────document.addEventListener("DOMContentLoaded", () => { initAccordions(); loadData(); setInterval(loadData, 15000); // poll every 15s});
let resizeTimer;window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => loadData(true), 250);});