'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 = `