Files
alpine-router/public/home.js
2026-04-15 11:38:26 +03:00

785 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();
})();