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(); |