'use strict'; let allClients = []; let searchQuery = ''; let editingClient = null; let modalSelectedPolicy = ''; // "" means "use default" // ── Default policy ───────────────────────────────────────────────────────── async function loadDefaultPolicy() { try { const res = await fetch('/api/clients/policy'); const json = await res.json(); if (!res.ok || !json.success) return; setDefaultPolicyUI(json.data.default || 'direct'); } catch (_) {} } function setDefaultPolicyUI(val) { document.querySelectorAll('#defaultPolicySelector .seg-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.val === val); }); } async function saveDefaultPolicy(val) { try { const res = await fetch('/api/clients/policy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ default: val }), }); const json = await res.json(); if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); setDefaultPolicyUI(val); showToast('Политика по умолчанию сохранена', 'success'); loadClients(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } } document.querySelectorAll('#defaultPolicySelector .seg-btn').forEach(btn => { btn.addEventListener('click', () => saveDefaultPolicy(btn.dataset.val)); }); // ── Apply-all ────────────────────────────────────────────────────────────── async function applyPolicyToAll(val) { const labels = { disabled: 'Отключён', direct: 'Напрямую', vpn: 'Через VPN' }; if (!confirm(`Применить политику «${labels[val]}» ко всем устройствам?`)) return; try { const res = await fetch('/api/clients/policy/apply-all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ policy: val }), }); const json = await res.json(); if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); showToast(`Политика применена к ${json.data.updated} устройствам`, 'success'); loadClients(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } } document.querySelectorAll('.policy-all-btn').forEach(btn => { btn.addEventListener('click', () => applyPolicyToAll(btn.dataset.val)); }); // ── Clients list ─────────────────────────────────────────────────────────── async function loadClients() { try { const res = await fetch('/api/clients'); const json = await res.json(); if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); allClients = json.data || []; render(); } catch (e) { showToast('Ошибка загрузки: ' + e.message, 'error'); } } const ONLINE_WINDOW_MS = 5 * 60 * 1000; function isOnline(c) { if (c.last_active) { return (Date.now() - c.last_active * 1000) < ONLINE_WINDOW_MS; } return c.online; } function fmtBytes(n) { if (!n) return '—'; const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']; let i = 0, v = n; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } function fmtLease(expiresUnix) { if (!expiresUnix) return null; const secs = expiresUnix - Math.floor(Date.now() / 1000); if (secs <= 0) return { text: 'истекла', cls: 'lease-expired' }; if (secs < 3600) return { text: `${Math.floor(secs / 60)} мин`, cls: 'lease-soon' }; if (secs < 86400) return { text: `${Math.floor(secs / 3600)} ч`, cls: '' }; return { text: `${Math.floor(secs / 86400)} д ${Math.floor((secs % 86400) / 3600)} ч`, cls: '' }; } function fmtLastActive(c) { const nowSec = Math.floor(Date.now() / 1000); if (c.last_active) { const ago = nowSec - c.last_active; if (ago < 10) return { text: 'только что', cls: 'active-now' }; if (ago < 60) return { text: `${ago} с назад`, cls: 'active-now' }; if (ago < 3600) return { text: `${Math.floor(ago / 60)} мин назад`, cls: ago < ONLINE_WINDOW_MS / 1000 ? 'active-now' : '' }; if (ago < 86400) return { text: `${Math.floor(ago / 3600)} ч назад`, cls: '' }; return { text: `${Math.floor(ago / 86400)} д назад`, cls: '' }; } if (c.is_dhcp && c.lease_expires) { const lease = fmtLease(c.lease_expires); return lease ? { text: `аренда: ${lease.text}`, cls: lease.cls } : { text: '—', cls: '' }; } return { text: '—', cls: '' }; } function matchesSearch(c, q) { if (!q) return true; const l = q.toLowerCase(); return ( (c.hostname || '').toLowerCase().includes(l) || (c.ip || '').includes(l) || (c.mac || '').toLowerCase().includes(l) || (c.interface || '').toLowerCase().includes(l) ); } function policyLabel(policy) { switch (policy) { case 'disabled': return { text: 'Отключён', cls: 'policy-badge policy-disabled' }; case 'vpn': return { text: 'VPN', cls: 'policy-badge policy-vpn' }; case 'direct': return { text: 'Напрямую', cls: 'policy-badge policy-direct' }; default: return { text: 'по умолч.', cls: 'policy-badge policy-default' }; } } function render() { const loading = document.getElementById('loading'); const wrap = document.getElementById('clientsTableWrap'); const empty = document.getElementById('emptyState'); const body = document.getElementById('clientsBody'); const summary = document.getElementById('clientsSummary'); loading.classList.add('hidden'); const onlineCount = allClients.filter(c => isOnline(c)).length; const dhcpCount = allClients.filter(c => c.is_dhcp).length; const disabledCount = allClients.filter(c => c.policy === 'disabled' || (c.policy === '' && c.blocked)).length; const vpnCount = allClients.filter(c => c.policy === 'vpn').length; summary.innerHTML = `${onlineCount} онлайн` + `${allClients.length} всего` + `${dhcpCount} по DHCP` + (vpnCount > 0 ? `${vpnCount} через VPN` : '') + (disabledCount > 0 ? `${disabledCount} отключено` : ''); const filtered = allClients.filter(c => matchesSearch(c, searchQuery)); if (filtered.length === 0) { wrap.classList.add('hidden'); empty.classList.remove('hidden'); empty.textContent = allClients.length === 0 ? 'Нет подключённых устройств' : `Ничего не найдено по запросу «${searchQuery}»`; return; } empty.classList.add('hidden'); wrap.classList.remove('hidden'); const sorted = [...filtered].sort((a, b) => { const ao = isOnline(a), bo = isOnline(b); if (ao !== bo) return ao ? -1 : 1; return ipToNum(a.ip) - ipToNum(b.ip); }); body.innerHTML = ''; sorted.forEach(c => body.appendChild(buildRow(c))); } function buildRow(c) { const online = isOnline(c); const tr = document.createElement('tr'); tr.className = 'client-row'; if (!online) tr.classList.add('row-offline'); const effectivePolicy = c.policy || (c.blocked ? 'disabled' : ''); if (effectivePolicy === 'disabled') tr.classList.add('row-blocked'); if (effectivePolicy === 'vpn') tr.classList.add('row-vpn'); const activity = fmtLastActive(c); const pl = policyLabel(effectivePolicy); const typeCell = c.is_dhcp ? 'DHCP' : 'ARP'; const hostname = c.hostname ? `${escHtml(c.hostname)}` : ''; const txHtml = c.tx_bytes ? `${fmtBytes(c.tx_bytes)}` : ''; const rxHtml = c.rx_bytes ? `${fmtBytes(c.rx_bytes)}` : ''; const actHtml = activity.text !== '—' ? `${activity.text}` : ''; const ipDisplay = c.static_ip ? `${escHtml(c.static_ip)} фикс.` : `${escHtml(c.ip)}`; tr.innerHTML = ` ${hostname} ${ipDisplay} ${escHtml(c.mac || '—')} ${escHtml(c.interface || '—')} ${typeCell} ${pl.text} ${txHtml} ${rxHtml} ${actHtml} `; tr.addEventListener('click', () => openModal(c)); return tr; } // ── Modal ────────────────────────────────────────────────────────────────── function openModal(c) { editingClient = { ...c }; const modal = document.getElementById('clientModal'); document.getElementById('modalTitle').textContent = c.hostname || c.ip || 'Устройство'; document.getElementById('modalHostname').value = c.hostname || ''; document.getElementById('modalHostname').placeholder = c.hostname ? '' : (c.mac || c.ip); const currentIP = c.static_ip || c.ip || '—'; document.getElementById('modalIP').textContent = currentIP; const currentLabel = document.getElementById('modalIPCurrent'); if (c.static_ip) { currentLabel.textContent = '(фиксированный)'; currentLabel.style.display = ''; } else if (c.ip) { currentLabel.textContent = '(DHCP: ' + c.ip + ')'; currentLabel.style.display = ''; } else { currentLabel.textContent = ''; currentLabel.style.display = 'none'; } document.getElementById('modalStaticIP').value = c.static_ip || ''; document.getElementById('modalMAC').textContent = c.mac || '—'; document.getElementById('modalIface').textContent = c.interface || '—'; // Set policy selector — empty string means "use default" const effectivePolicy = c.policy || ''; modalSelectedPolicy = effectivePolicy; updateModalPolicySelector(effectivePolicy); modal.classList.remove('hidden'); document.getElementById('modalHostname').focus(); } function updateModalPolicySelector(val) { document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.val === val); }); const hint = document.getElementById('modalPolicyHint'); const descriptions = { disabled: 'Устройство заблокировано — нет доступа в интернет', direct: 'Трафик идёт напрямую через NAT, минуя VPN', vpn: 'Трафик перенаправляется через Mihomo (tproxy)', '': 'Используется политика по умолчанию', }; hint.textContent = descriptions[val] ?? ''; } document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => { btn.addEventListener('click', () => { modalSelectedPolicy = btn.dataset.val; updateModalPolicySelector(btn.dataset.val); }); }); function closeModal() { document.getElementById('clientModal').classList.add('hidden'); editingClient = null; } async function saveClient() { if (!editingClient) return; const mac = editingClient.mac; if (!mac) { showToast('У устройства нет MAC-адреса', 'error'); return; } const hostname = document.getElementById('modalHostname').value.trim(); const staticIP = document.getElementById('modalStaticIP').value.trim(); const policy = modalSelectedPolicy; // "disabled" | "direct" | "vpn" | "" const btn = document.getElementById('modalSave'); btn.disabled = true; try { const res = await fetch('/api/clients/update/' + encodeURIComponent(mac), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostname, blocked: policy === 'disabled', static_ip: staticIP, policy, }), }); const json = await res.json(); if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); showToast('Настройки сохранены', 'success'); closeModal(); loadClients(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } finally { btn.disabled = false; } } // ── Utilities ────────────────────────────────────────────────────────────── function ipToNum(ip) { return (ip || '').split('.').reduce((acc, o) => acc * 256 + (+o || 0), 0); } function escHtml(s) { return String(s) .replace(/&/g, '&').replace(//g, '>'); } let toastTimer; function showToast(msg, type = 'info') { const t = document.getElementById('toast'); t.textContent = msg; t.className = `toast ${type}`; t.classList.remove('hidden'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.add('hidden'), 3500); } // ── Event wiring ─────────────────────────────────────────────────────────── document.getElementById('clientsSearch').addEventListener('input', e => { searchQuery = e.target.value.trim(); render(); }); document.getElementById('modalBackdrop').addEventListener('click', closeModal); document.getElementById('modalClose').addEventListener('click', closeModal); document.getElementById('modalCancel').addEventListener('click', closeModal); document.getElementById('clientForm').addEventListener('submit', e => { e.preventDefault(); saveClient(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); setInterval(loadClients, 10000); loadDefaultPolicy(); loadClients();