const $ = (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: ``, arrowDown: ``, arrowUp: ``, activity: ``, }; // ── 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 = 'Offline'; } else if (loss > 1) { badge.className = "status-badge degraded"; badge.innerHTML = 'Degraded'; } else { badge.className = "status-badge online"; badge.innerHTML = '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 => `
${m.label}
${m.icon}
${m.value}
${m.sub}
${m.spark}
` ).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 => `
${t.host}
${t.ip}
${t.latency} ms ${t.loss}% ` ).join(""); } function renderDNS(dns) { if (!dns) return; $("#dnsBody").innerHTML = dns.map(d => ` ${d.host} ${d.time_ms} ms ` ).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 '' + ''; } // ── 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: '', error: '', ok: '', }; const el = document.createElement('div'); el.className = 'toast ' + type; el.innerHTML = '' + (icons[type] || icons.warn) + '' + '
' + title + '
' + (msg ? '
' + msg + '
' : '') + '
'; 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); });