314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
|
|
'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 =
|
|||
|
|
`<span class="cl-stat"><span class="state-dot up"></span>${onlineCount} онлайн</span>` +
|
|||
|
|
`<span class="cl-stat cl-stat--muted">${allClients.length} всего</span>` +
|
|||
|
|
`<span class="cl-stat cl-stat--muted">${dhcpCount} по DHCP</span>` +
|
|||
|
|
(blockedCount > 0 ? `<span class="cl-stat cl-stat--blocked">${blockedCount} заблокировано</span>` : '');
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
? '<span class="client-badge dhcp">DHCP</span>'
|
|||
|
|
: '<span class="client-badge arp">ARP</span>';
|
|||
|
|
|
|||
|
|
const hostname = c.hostname
|
|||
|
|
? `<span class="client-host">${escHtml(c.hostname)}</span>`
|
|||
|
|
: '<span class="none">—</span>';
|
|||
|
|
|
|||
|
|
const txHtml = c.tx_bytes
|
|||
|
|
? `<span class="traffic-num">${fmtBytes(c.tx_bytes)}</span>`
|
|||
|
|
: '<span class="none">—</span>';
|
|||
|
|
|
|||
|
|
const rxHtml = c.rx_bytes
|
|||
|
|
? `<span class="traffic-num">${fmtBytes(c.rx_bytes)}</span>`
|
|||
|
|
: '<span class="none">—</span>';
|
|||
|
|
|
|||
|
|
const actHtml = activity.text !== '—'
|
|||
|
|
? `<span class="activity-val ${activity.cls}">${activity.text}</span>`
|
|||
|
|
: '<span class="none">—</span>';
|
|||
|
|
|
|||
|
|
const ipDisplay = c.static_ip
|
|||
|
|
? `<span class="mono">${escHtml(c.static_ip)}</span> <span class="client-badge static-badge">фикс.</span>`
|
|||
|
|
: `<span class="mono">${escHtml(c.ip)}</span>`;
|
|||
|
|
|
|||
|
|
const blockedBadge = c.blocked
|
|||
|
|
? ' <span class="client-badge blocked-badge">ЗАБЛОКИРОВАН</span>'
|
|||
|
|
: '';
|
|||
|
|
|
|||
|
|
tr.innerHTML = `
|
|||
|
|
<td class="col-status">
|
|||
|
|
<span class="state-dot ${online ? 'up' : 'down'}"
|
|||
|
|
title="${online ? 'онлайн' : 'офлайн'}"></span>
|
|||
|
|
</td>
|
|||
|
|
<td class="col-host">${hostname}${blockedBadge}</td>
|
|||
|
|
<td class="col-ip">${ipDisplay}</td>
|
|||
|
|
<td class="col-mac"><span class="mono muted">${escHtml(c.mac || '—')}</span></td>
|
|||
|
|
<td class="col-iface">${escHtml(c.interface || '—')}</td>
|
|||
|
|
<td class="col-type">${typeCell}</td>
|
|||
|
|
<td class="col-tx">${txHtml}</td>
|
|||
|
|
<td class="col-rx">${rxHtml}</td>
|
|||
|
|
<td class="col-activity">${actHtml}</td>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
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, '<').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();
|