'use strict'; const $ = id => document.getElementById(id); let dashData = null; let speedMode = 'real'; let chartSamples = []; let chartHoverIdx = null; function fmtSpeed(bps) { if (bps === 0) return '0 bps'; const units = ['bps', 'Kbps', 'Mbps', 'Gbps']; let v = bps; let i = 0; while (v >= 1000 && i < units.length - 1) { v /= 1000; i++; } if (i === 0) return Math.round(v) + ' bps'; return v.toFixed(1) + ' ' + units[i]; } function fmtBytes(n) { if (n === undefined || n === null) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } function fmtTime(d) { return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } function countryFlag(cc) { if (!cc || cc.length !== 2) return ''; const base = 0x1F1E6; return String.fromCodePoint(base + cc.toUpperCase().charCodeAt(0) - 65) + String.fromCodePoint(base + cc.toUpperCase().charCodeAt(1) - 65); } function setArc(id, pct, colorVar) { const el = $(id); if (!el) return; const r = 42; const c = 2 * Math.PI * r; const offset = c - (pct / 100) * c; el.style.strokeDasharray = c; el.style.strokeDashoffset = offset; if (colorVar) el.style.stroke = colorVar; } async function api(method, path, body) { const opts = { method, headers: body ? { 'Content-Type': 'application/json' } : {} }; if (body) opts.body = JSON.stringify(body); const res = await fetch(path, opts); const json = await res.json(); if (!res.ok || !json.success) throw new Error(json.error || 'HTTP ' + res.status); return json.data; } const get = path => api('GET', path); const post = (path, body) => api('POST', path, body); async function loadData() { try { dashData = await get('/api/dashboard'); render(); } catch (e) { console.error('dashboard load error', e); } $('loading').classList.add('hidden'); } function render() { if (!dashData) return; const d = dashData; // System const cpuPct = d.system.cpu_pct || 0; const memPct = d.system.mem_pct || 0; setArc('cpuArc', cpuPct, cpuPct > 80 ? 'var(--danger)' : 'var(--accent)'); setArc('memArc', memPct, memPct > 90 ? 'var(--danger)' : 'var(--success)'); $('cpuVal').textContent = Math.round(cpuPct) + '%'; $('memVal').textContent = Math.round(memPct) + '%'; const sysLines = []; if (d.system.mem_total) { sysLines.push(fmtBytes(d.system.mem_used) + ' / ' + fmtBytes(d.system.mem_total)); } if (d.system.uptime) { const u = d.system.uptime; const days = Math.floor(u / 86400); const hrs = Math.floor((u % 86400) / 3600); const mins = Math.floor((u % 3600) / 60); sysLines.push('Uptime: ' + (days ? days + 'д ' : '') + hrs + 'ч ' + mins + 'м'); } $('sysInfo').innerHTML = sysLines.map(l => '
' + l + '
').join(''); // Mihomo const mihomoRunning = d.mihomo.running; const badge = $('mihomoBadge'); const toggle = $('mihomoToggle'); const restartBtn = $('mihomoRestartBtn'); const pidEl = $('mihomoPid'); if (mihomoRunning) { badge.className = 'svc-badge running'; badge.textContent = 'Запущен'; pidEl.textContent = 'PID ' + (d.mihomo.pid || '?'); toggle.checked = true; restartBtn.disabled = false; } else { badge.className = 'svc-badge stopped'; badge.textContent = 'Остановлен'; pidEl.textContent = ''; toggle.checked = false; restartBtn.disabled = true; } // Connectivity Status Banner const conn = d.connectivity; const banner = $('statusBanner'); const icon = $('statusIcon'); const title = $('statusTitle'); const subtitle = $('statusSubtitle'); banner.classList.remove('hidden', 'status-online', 'status-warning', 'status-offline'); if (conn.direct_up) { banner.classList.add('status-online'); icon.innerHTML = ''; if (!conn.vpn_up && mihomoRunning) { banner.classList.remove('status-online'); banner.classList.add('status-warning'); icon.innerHTML = ''; title.textContent = 'Интернет есть, но VPN недоступен'; subtitle.textContent = 'Прямое подключение работает, но VPN-туннель не отвечает'; } else { title.textContent = 'Мы онлайн'; subtitle.textContent = conn.vpn_up && mihomoRunning ? 'Интернет и VPN работают' : 'Подключение к интернету активно'; } } else { banner.classList.add('status-offline'); icon.innerHTML = ''; title.textContent = 'Нет интернет-соединения'; subtitle.textContent = 'Проверьте подключение к сети'; } // Connectivity timelines renderTimeline('directTimeline', conn.minutes_direct, conn.direct_up); renderTimeline('vpnTimeline', conn.minutes_vpn, conn.vpn_up); // Current connectivity status markers $('directStatus').innerHTML = conn.direct_up ? '● Доступен ' + (conn.direct_up_since ? conn.direct_up_since : '') + '' : '● Недоступен'; $('vpnStatus').innerHTML = mihomoRunning ? (conn.vpn_up ? '● Доступен ' + (conn.vpn_up_since ? conn.vpn_up_since : '') + '' : '● Недоступен') : '— Не запущен'; // Traffic speed const samples = speedMode === 'real' ? (d.traffic_real || []) : (d.traffic_avg || []); $('gwIface').textContent = d.gateway_iface ? ('Шлюз: ' + d.gateway_iface) : ''; const lastSample = samples.length > 0 ? samples[samples.length - 1] : null; $('rxSpeed').textContent = lastSample ? fmtSpeed(lastSample.rx_bps) : '0 bps'; $('txSpeed').textContent = lastSample ? fmtSpeed(lastSample.tx_bps) : '0 bps'; drawChart(samples); // IP Info renderIP('ipDirect', d.ip_direct); renderIP('ipVPN', d.ip_vpn, !mihomoRunning); } function renderTimeline(containerId, minutes) { const container = $(containerId); if (!container) return; const n = minutes.length; let html = '
'; for (let i = 0; i < n; i++) { const m = minutes[i]; let cls = 'tl-bar'; if (m.status === 'up') cls += ' tl-up'; else if (m.status === 'down') cls += ' tl-down'; else cls += ' tl-na'; const minuteTime = new Date(m.minute * 60000); const label = minuteTime.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const pingsData = m.pings || {}; const pingsJson = encodeURIComponent(JSON.stringify(pingsData)); const dataPingCF = m.ping_cf || ''; const dataPingGG = m.ping_gg || ''; html += '
'; } html += '
'; html += '
1ч назад45м30м15мСейчас
'; container.innerHTML = html; } function drawChart(samples) { chartSamples = samples || []; const canvas = $('speedChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); _drawChartContent(ctx, rect.width, rect.height, chartSamples, chartHoverIdx); } // Chart inner padding — must match CHART_PAD in event handlers below const CHART_PAD = { top: 18, right: 20, bottom: 34, left: 72 }; function _drawChartContent(ctx, w, h, samples, hoverIdx) { const pad = CHART_PAD; const cw = w - pad.left - pad.right; const ch = h - pad.top - pad.bottom; ctx.clearRect(0, 0, w, h); if (!samples || samples.length < 2) { ctx.fillStyle = 'rgba(122, 162, 204, 0.4)'; ctx.font = '13px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Нет данных', w / 2, h / 2); return; } let maxVal = 1000; for (const s of samples) { if (s.rx_bps > maxVal) maxVal = s.rx_bps; if (s.tx_bps > maxVal) maxVal = s.tx_bps; } // Round up to a clean value const rawMax = maxVal * 1.2; const mag = Math.pow(10, Math.floor(Math.log10(rawMax))); maxVal = Math.ceil(rawMax / mag) * mag; const timeStart = new Date(samples[0].time).getTime(); const timeEnd = new Date(samples[samples.length - 1].time).getTime(); const timeRange = timeEnd - timeStart || 1; function xOf(s) { return pad.left + ((new Date(s.time).getTime() - timeStart) / timeRange) * cw; } function yOf(bps) { return pad.top + ch * (1 - bps / maxVal); } // ── Y-axis grid + labels ── const gridLines = 5; ctx.font = '10px JetBrains Mono, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let i = 0; i <= gridLines; i++) { const y = pad.top + (ch / gridLines) * i; const val = maxVal * (1 - i / gridLines); ctx.strokeStyle = i === gridLines ? 'rgba(0,200,255,0.18)' : 'rgba(0,200,255,0.06)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(w - pad.right, y); ctx.stroke(); ctx.fillStyle = 'rgba(122,162,204,0.9)'; ctx.fillText(fmtSpeed(val), pad.left - 8, y); } // ── X-axis time labels ── ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.font = '10px JetBrains Mono, monospace'; ctx.fillStyle = 'rgba(122,162,204,0.6)'; const minGap = 70; const pxPerSmp = samples.length > 1 ? cw / (samples.length - 1) : cw; const step = Math.max(1, Math.ceil(minGap / pxPerSmp)); const shownLbl = new Set(); for (let i = 0; i < samples.length; i += step) { const x = xOf(samples[i]); ctx.fillText(fmtTime(new Date(samples[i].time)), x, pad.top + ch + 6); shownLbl.add(i); ctx.strokeStyle = 'rgba(0,200,255,0.14)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pad.top + ch); ctx.lineTo(x, pad.top + ch + 4); ctx.stroke(); } // Always show last label if not too close to previous const lastIdx = samples.length - 1; if (!shownLbl.has(lastIdx)) { const lastX = xOf(samples[lastIdx]); const prevI = Math.floor(lastIdx / step) * step; const prevX = xOf(samples[Math.min(prevI, lastIdx)]); if (lastX - prevX > 44) { ctx.fillText(fmtTime(new Date(samples[lastIdx].time)), lastX, pad.top + ch + 6); ctx.strokeStyle = 'rgba(0,200,255,0.14)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lastX, pad.top + ch); ctx.lineTo(lastX, pad.top + ch + 4); ctx.stroke(); } } const baseY = pad.top + ch; // ── Cardinal-spline path helper (control points clamped to chart bounds) ── function smoothPath(pts) { if (pts.length < 2) return; ctx.moveTo(pts[0].x, pts[0].y); const t = 0.2; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[Math.max(i - 1, 0)]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[Math.min(i + 2, pts.length - 1)]; const clampY = v => Math.max(pad.top, Math.min(baseY, v)); ctx.bezierCurveTo( p1.x + (p2.x - p0.x) * t, clampY(p1.y + (p2.y - p0.y) * t), p2.x - (p3.x - p1.x) * t, clampY(p2.y - (p3.y - p1.y) * t), p2.x, p2.y ); } } const rxPts = samples.map(s => ({ x: xOf(s), y: yOf(s.rx_bps) })); const txPts = samples.map(s => ({ x: xOf(s), y: yOf(s.tx_bps) })); // ── Clip fills + lines to plot area so bezier curves can't overflow ── ctx.save(); ctx.beginPath(); ctx.rect(pad.left, pad.top, cw, ch); ctx.clip(); // ── RX fill ── const rxGrad = ctx.createLinearGradient(0, pad.top, 0, baseY); rxGrad.addColorStop(0, 'rgba(0,212,255,0.22)'); rxGrad.addColorStop(1, 'rgba(0,212,255,0.01)'); ctx.beginPath(); smoothPath(rxPts); ctx.lineTo(rxPts[rxPts.length - 1].x, baseY); ctx.lineTo(rxPts[0].x, baseY); ctx.closePath(); ctx.fillStyle = rxGrad; ctx.fill(); // ── RX line ── ctx.beginPath(); smoothPath(rxPts); ctx.strokeStyle = 'rgba(0,212,255,0.95)'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); // ── TX fill ── const txGrad = ctx.createLinearGradient(0, pad.top, 0, baseY); txGrad.addColorStop(0, 'rgba(0,255,136,0.16)'); txGrad.addColorStop(1, 'rgba(0,255,136,0.01)'); ctx.beginPath(); smoothPath(txPts); ctx.lineTo(txPts[txPts.length - 1].x, baseY); ctx.lineTo(txPts[0].x, baseY); ctx.closePath(); ctx.fillStyle = txGrad; ctx.fill(); // ── TX line ── ctx.beginPath(); smoothPath(txPts); ctx.strokeStyle = 'rgba(0,255,136,0.9)'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); ctx.restore(); // end clip // ── Legend (top-right, clear of Y-axis labels) ── ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.font = '11px Inter, sans-serif'; const ly = pad.top + 11; const lx = w - pad.right - 104; ctx.fillStyle = 'rgba(0,212,255,0.9)'; ctx.fillRect(lx, ly - 1, 14, 2); ctx.fillStyle = 'rgba(122,162,204,0.85)'; ctx.fillText('↓ RX', lx + 18, ly); ctx.fillStyle = 'rgba(0,255,136,0.9)'; ctx.fillRect(lx + 58, ly - 1, 14, 2); ctx.fillStyle = 'rgba(122,162,204,0.85)'; ctx.fillText('↑ TX', lx + 76, ly); // ── Hover: crosshair + glowing dots ── if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < samples.length) { const s = samples[hoverIdx]; const hx = xOf(s); const ry = yOf(s.rx_bps); const ty = yOf(s.tx_bps); // Vertical dashed crosshair ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(hx, pad.top); ctx.lineTo(hx, baseY); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); // Horizontal guide lines to Y-axis ctx.save(); ctx.lineWidth = 1; ctx.setLineDash([2, 5]); ctx.strokeStyle = 'rgba(0,212,255,0.2)'; ctx.beginPath(); ctx.moveTo(pad.left, ry); ctx.lineTo(hx, ry); ctx.stroke(); ctx.strokeStyle = 'rgba(0,255,136,0.18)'; ctx.beginPath(); ctx.moveTo(pad.left, ty); ctx.lineTo(hx, ty); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); // RX dot — outer glow ctx.beginPath(); ctx.arc(hx, ry, 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,212,255,0.15)'; ctx.fill(); // RX dot — core ctx.beginPath(); ctx.arc(hx, ry, 4.5, 0, Math.PI * 2); ctx.fillStyle = '#00d4ff'; ctx.fill(); ctx.strokeStyle = 'rgba(10,14,26,0.9)'; ctx.lineWidth = 1.5; ctx.stroke(); // TX dot — outer glow ctx.beginPath(); ctx.arc(hx, ty, 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,255,136,0.12)'; ctx.fill(); // TX dot — core ctx.beginPath(); ctx.arc(hx, ty, 4.5, 0, Math.PI * 2); ctx.fillStyle = '#00ff88'; ctx.fill(); ctx.strokeStyle = 'rgba(10,14,26,0.9)'; ctx.lineWidth = 1.5; ctx.stroke(); } } function renderIP(containerId, info, disabled) { const el = $(containerId); if (!el) return; if (disabled) { el.innerHTML = '
Mihomo не запущен
'; return; } if (!info) { el.innerHTML = '
Определение...
'; return; } const flag = info.cc ? countryFlag(info.cc) + ' ' : ''; el.innerHTML = '
' + info.ip + '
' + '
' + flag + (info.country || '') + '
'; } // Mihomo controls $('mihomoToggle').addEventListener('change', async (e) => { const action = e.target.checked ? 'start' : 'stop'; try { await post('/api/mihomo/' + action, null); showToast('Mihomo ' + (action === 'start' ? 'запущен' : 'остановлен'), 'success'); await loadData(); } catch (err) { showToast('Ошибка: ' + err.message, 'error'); await loadData(); } }); $('mihomoRestartBtn').addEventListener('click', async () => { try { await post('/api/mihomo/restart', null); showToast('Mihomo перезапущен', 'success'); await loadData(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); // Speed mode switch $('speedModeSwitch').addEventListener('click', (e) => { const btn = e.target.closest('.seg-btn'); if (!btn) return; speedMode = btn.dataset.mode; $('speedModeSwitch').querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); if (dashData) render(); }); // Connectivity settings modal function openConnSettings() { const conn = dashData ? dashData.connectivity : null; const defaultDirect = [ { name: 'Cloudflare', url: 'http://cp.cloudflare.com/generate_204' }, { name: 'Google', url: 'http://connectivitycheck.gstatic.com/generate_204' }, ]; const direct = (conn && conn.endpoint_names) ? [] : defaultDirect; if (dashData && dashData.connectivity) { fetch('/api/config.yaml', { method: 'GET' }) .then(r => r.json()) .then(j => { const c = j.data && j.data.connectivity ? j.data.connectivity : {}; const d = c.direct || defaultDirect; const v = c.via_proxy || defaultDirect; $('direct1Name').value = (d[0] && d[0].name) || ''; $('direct1Url').value = (d[0] && d[0].url) || ''; $('direct2Name').value = (d[1] && d[1].name) || ''; $('direct2Url').value = (d[1] && d[1].url) || ''; $('proxy1Name').value = (v[0] && v[0].name) || ''; $('proxy1Url').value = (v[0] && v[0].url) || ''; $('proxy2Name').value = (v[1] && v[1].name) || ''; $('proxy2Url').value = (v[1] && v[1].url) || ''; }) .catch(() => { $('direct1Name').value = defaultDirect[0].name; $('direct1Url').value = defaultDirect[0].url; $('direct2Name').value = defaultDirect[1].name; $('direct2Url').value = defaultDirect[1].url; $('proxy1Name').value = defaultDirect[0].name; $('proxy1Url').value = defaultDirect[0].url; $('proxy2Name').value = defaultDirect[1].name; $('proxy2Url').value = defaultDirect[1].url; }); } else { $('direct1Name').value = defaultDirect[0].name; $('direct1Url').value = defaultDirect[0].url; $('direct2Name').value = defaultDirect[1].name; $('direct2Url').value = defaultDirect[1].url; $('proxy1Name').value = defaultDirect[0].name; $('proxy1Url').value = defaultDirect[0].url; $('proxy2Name').value = defaultDirect[1].name; $('proxy2Url').value = defaultDirect[1].url; } $('connModal').classList.remove('hidden'); } function closeConnSettings() { $('connModal').classList.add('hidden'); } async function saveConnSettings(e) { if (e) e.preventDefault(); const endpoints = { direct: [ { name: $('direct1Name').value.trim(), url: $('direct1Url').value.trim() }, { name: $('direct2Name').value.trim(), url: $('direct2Url').value.trim() }, ].filter(ep => ep.name && ep.url), via_proxy: [ { name: $('proxy1Name').value.trim(), url: $('proxy1Url').value.trim() }, { name: $('proxy2Name').value.trim(), url: $('proxy2Url').value.trim() }, ].filter(ep => ep.name && ep.url), }; if (endpoints.direct.length === 0) { showToast('Укажите хотя бы одну точку для прямого подключения', 'error'); return; } if (endpoints.via_proxy.length === 0) { showToast('Укажите хотя бы одну точку для проверки через прокси', 'error'); return; } try { const cfgRes = await get('/api/config.yaml'); cfgRes.connectivity = endpoints; const resp = await fetch('/api/config.yaml', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfgRes), }); const j = await resp.json(); if (!j.success) throw new Error(j.error || 'save failed'); showToast('Настройки подключения сохранены', 'success'); closeConnSettings(); } catch (err) { showToast('Ошибка: ' + err.message, 'error'); } } $('connSettingsBtn').addEventListener('click', openConnSettings); $('closeConnModal').addEventListener('click', closeConnSettings); $('cancelConnSettings').addEventListener('click', closeConnSettings); $('connModalBackdrop').addEventListener('click', closeConnSettings); $('connSettingsForm').addEventListener('submit', saveConnSettings); let toastTimer; function showToast(msg, type) { const t = $('toast'); t.textContent = msg; t.className = 'toast ' + type; t.classList.remove('hidden'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.add('hidden'), 3500); } // Resize chart window.addEventListener('resize', () => { if (dashData) { const samples = speedMode === 'real' ? dashData.traffic_real : dashData.traffic_avg; drawChart(samples || []); } }); // Auto-refresh setInterval(loadData, 500); // ── Custom timeline tooltip ── const tlTooltip = document.createElement('div'); tlTooltip.className = 'tl-tooltip'; tlTooltip.style.left = '-9999px'; tlTooltip.style.top = '-9999px'; document.body.appendChild(tlTooltip); // ── Chart hover tooltip ── const chartTooltip = document.createElement('div'); chartTooltip.className = 'chart-tooltip'; document.body.appendChild(chartTooltip); let tlTooltipTimer = null; let tlCursorX = 0; let tlCursorY = 0; document.addEventListener('mousemove', (e) => { const zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1; tlCursorX = e.clientX / zoom; tlCursorY = e.clientY / zoom; const bar = e.target.closest('.tl-bar'); if (!bar) { tlTooltip.classList.remove('visible'); return; } if (tlTooltip.classList.contains('visible')) positionTooltip(); }); document.addEventListener('mouseover', (e) => { const bar = e.target.closest('.tl-bar'); if (!bar) return; const time = bar.dataset.time; const status = bar.dataset.status; let statusText, dotClass; if (status === 'up') { statusText = 'Доступен'; dotClass = 'up'; } else if (status === 'down') { statusText = 'Недоступен'; dotClass = 'down'; } else { statusText = 'Нет данных'; dotClass = 'na'; } let pingHtml = ''; if (status === 'up') { const parts = []; const pingsData = bar.dataset.pings; if (pingsData) { try { const pings = JSON.parse(decodeURIComponent(pingsData)); for (const [name, ms] of Object.entries(pings)) { if (ms > 0) parts.push(name + ': ' + ms + ' мс'); } } catch (_) { const pingCF = bar.dataset.pingCf; const pingGG = bar.dataset.pingGg; if (pingCF) parts.push('CF: ' + pingCF + ' мс'); if (pingGG) parts.push('Google: ' + pingGG + ' мс'); } } else { const pingCF = bar.dataset.pingCf; const pingGG = bar.dataset.pingGg; if (pingCF) parts.push('CF: ' + pingCF + ' мс'); if (pingGG) parts.push('Google: ' + pingGG + ' мс'); } if (parts.length) pingHtml = '' + parts.join(', ') + ''; } tlTooltip.innerHTML = '
' + time + '
' + '
' + statusText + pingHtml + '
'; void tlTooltip.offsetWidth; positionTooltip(); tlTooltip.classList.add('visible'); clearTimeout(tlTooltipTimer); }); document.addEventListener('mouseout', (e) => { const bar = e.target.closest('.tl-bar'); if (!bar) return; const related = e.relatedTarget; if (related && related.closest && related.closest('.tl-bar')) return; tlTooltipTimer = setTimeout(() => tlTooltip.classList.remove('visible'), 80); }); function positionTooltip() { const tw = tlTooltip.offsetWidth; const th = tlTooltip.offsetHeight; let left = tlCursorX - tw / 2; let top = tlCursorY - th - 12; if (top < 4) top = tlCursorY + 16; if (left < 4) left = 4; if (left + tw > window.innerWidth - 4) left = window.innerWidth - tw - 4; tlTooltip.style.left = left + 'px'; tlTooltip.style.top = top + 'px'; } // ── Chart hover / tooltip logic ── function hideChartTooltip() { chartTooltip.classList.remove('visible'); if (chartHoverIdx !== null) { chartHoverIdx = null; redrawChartHover(); } } function redrawChartHover() { const canvas = $('speedChart'); if (!canvas || !chartSamples || chartSamples.length < 2) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); _drawChartContent(ctx, rect.width, rect.height, chartSamples, chartHoverIdx); } $('speedChart').addEventListener('mousemove', (e) => { if (!chartSamples || chartSamples.length < 2) { hideChartTooltip(); return; } const canvas = $('speedChart'); const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; // offset within canvas in clientX pixels const pad = CHART_PAD; const cw = rect.width - pad.left - pad.right; if (mx < pad.left - 6 || mx > rect.width - pad.right + 6) { hideChartTooltip(); return; } const timeStart = new Date(chartSamples[0].time).getTime(); const timeEnd = new Date(chartSamples[chartSamples.length - 1].time).getTime(); const timeRange = timeEnd - timeStart || 1; let closestIdx = 0, closestDist = Infinity; for (let i = 0; i < chartSamples.length; i++) { const sx = pad.left + ((new Date(chartSamples[i].time).getTime() - timeStart) / timeRange) * cw; const d = Math.abs(mx - sx); if (d < closestDist) { closestDist = d; closestIdx = i; } } if (closestIdx !== chartHoverIdx) { chartHoverIdx = closestIdx; redrawChartHover(); } // Position tooltip above the hovered data point const s = chartSamples[closestIdx]; const sx = pad.left + ((new Date(s.time).getTime() - timeStart) / timeRange) * cw; const zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1; chartTooltip.innerHTML = '
' + fmtTime(new Date(s.time)) + '
' + '
' + '↓ RX' + '' + fmtSpeed(s.rx_bps) + '
' + '
' + '↑ TX' + '' + fmtSpeed(s.tx_bps) + '
'; const ttW = chartTooltip.offsetWidth; const ttH = chartTooltip.offsetHeight; const anchorX = (rect.left + sx) / zoom; const anchorY = rect.top / zoom; let left = anchorX - ttW / 2; let top = anchorY - ttH - 12; if (top < 4) top = (rect.bottom / zoom) + 8; if (left < 4) left = 4; if (left + ttW > window.innerWidth / zoom - 4) left = window.innerWidth / zoom - ttW - 4; chartTooltip.style.left = left + 'px'; chartTooltip.style.top = top + 'px'; chartTooltip.classList.add('visible'); }); $('speedChart').addEventListener('mouseleave', hideChartTooltip); (async () => { try { const status = await get('/api/auth/status'); if (status.default_password) { $('defaultPwWarning').classList.remove('hidden'); } } catch (_) {} await loadData(); })();