'use strict'; let allClients = []; let searchQuery = ''; let editingClient = null; 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 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 blockedCount = allClients.filter(c => c.blocked).length; summary.innerHTML = `${onlineCount} онлайн` + `${allClients.length} всего` + `${dhcpCount} по DHCP` + (blockedCount > 0 ? `${blockedCount} заблокировано` : ''); 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'); if (c.blocked) tr.classList.add('row-blocked'); const activity = fmtLastActive(c); 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)}`; const blockedBadge = c.blocked ? ' ЗАБЛОКИРОВАН' : ''; tr.innerHTML = ` ${hostname}${blockedBadge} ${ipDisplay} ${escHtml(c.mac || '—')} ${escHtml(c.interface || '—')} ${typeCell} ${txHtml} ${rxHtml} ${actHtml} `; tr.addEventListener('click', () => openModal(c)); return tr; } 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 || '—'; const blocked = document.getElementById('modalBlocked'); blocked.checked = !c.blocked; updateBlockedToggle(c.blocked); modal.classList.remove('hidden'); document.getElementById('modalHostname').focus(); } function updateBlockedToggle(isBlocked) { const hint = document.getElementById('modalBlockHint'); const toggle = document.getElementById('modalBlockToggle'); const toggleContainer = document.getElementById('modalBlocked'); if (isBlocked) { hint.textContent = 'Доступ в интернет заблокирован'; hint.style.color = 'var(--danger)'; toggleContainer.checked = false; toggle.classList.add('toggle-blocked'); } else { hint.textContent = 'Отключите, чтобы запретить устройству выход в интернет'; hint.style.color = ''; toggleContainer.checked = true; toggle.classList.remove('toggle-blocked'); } } 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 isBlocked = !document.getElementById('modalBlocked').checked; const staticIP = document.getElementById('modalStaticIP').value.trim(); 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: isBlocked, static_ip: staticIP }) }); 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; } } 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); } document.getElementById('refreshBtn').addEventListener('click', loadClients); 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.getElementById('modalBlocked').addEventListener('change', () => { if (!editingClient) return; const isBlocked = !document.getElementById('modalBlocked').checked; updateBlockedToggle(isBlocked); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); setInterval(loadClients, 10000); loadClients();