Files
alpine-router/public/clients.js
2026-04-13 09:46:02 +03:00

314 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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();