Files
alpine-router/public/home.js

785 lines
28 KiB
JavaScript
Raw Normal View History

2026-04-15 11:38:26 +03:00
'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 => '<div class="sys-line">' + l + '</div>').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 = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>';
if (!conn.vpn_up && mihomoRunning) {
banner.classList.remove('status-online');
banner.classList.add('status-warning');
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
title.textContent = 'Интернет есть, но VPN недоступен';
subtitle.textContent = 'Прямое подключение работает, но VPN-туннель не отвечает';
} else {
title.textContent = 'Мы онлайн';
subtitle.textContent = conn.vpn_up && mihomoRunning ? 'Интернет и VPN работают' : 'Подключение к интернету активно';
}
} else {
banner.classList.add('status-offline');
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
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
? '<span class="conn-up">● Доступен ' + (conn.direct_up_since ? conn.direct_up_since : '') + '</span>'
: '<span class="conn-down">● Недоступен</span>';
$('vpnStatus').innerHTML = mihomoRunning
? (conn.vpn_up ? '<span class="conn-up">● Доступен ' + (conn.vpn_up_since ? conn.vpn_up_since : '') + '</span>' : '<span class="conn-down">● Недоступен</span>')
: '<span class="conn-na">— Не запущен</span>';
// 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 = '<div class="tl-bars">';
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 += '<div class="' + cls + '" style="width:calc(100%/' + n + ')" data-time="' + label + '" data-status="' + m.status + '" data-pings="' + pingsJson + '" data-ping-cf="' + dataPingCF + '" data-ping-gg="' + dataPingGG + '"></div>';
}
html += '</div>';
html += '<div class="tl-labels"><span>1ч назад</span><span>45м</span><span>30м</span><span>15м</span><span>Сейчас</span></div>';
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 = '<div class="ip-address ip-na">Mihomo не запущен</div><div class="ip-country"></div>';
return;
}
if (!info) {
el.innerHTML = '<div class="ip-address ip-loading">Определение...</div><div class="ip-country"></div>';
return;
}
const flag = info.cc ? countryFlag(info.cc) + ' ' : '';
el.innerHTML = '<div class="ip-address">' + info.ip + '</div>' +
'<div class="ip-country">' + flag + (info.country || '') + '</div>';
}
// 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 = '<span class="tl-tooltip-ping">' + parts.join(', ') + '</span>';
}
tlTooltip.innerHTML =
'<div class="tl-tooltip-time">' + time + '</div>' +
'<div class="tl-tooltip-row"><span class="tl-tooltip-dot ' + dotClass + '"></span>' + statusText + pingHtml + '</div>';
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 =
'<div class="chart-tt-time">' + fmtTime(new Date(s.time)) + '</div>' +
'<div class="chart-tt-row"><span class="chart-tt-dot rx"></span>' +
'<span class="chart-tt-label">↓ RX</span>' +
'<span class="chart-tt-val">' + fmtSpeed(s.rx_bps) + '</span></div>' +
'<div class="chart-tt-row"><span class="chart-tt-dot tx"></span>' +
'<span class="chart-tt-label">↑ TX</span>' +
'<span class="chart-tt-val">' + fmtSpeed(s.tx_bps) + '</span></div>';
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();
})();