Files

402 lines
15 KiB
JavaScript
Raw Permalink Normal View History

2026-04-13 09:46:02 +03:00
'use strict';
let allClients = [];
let searchQuery = '';
let editingClient = null;
2026-04-15 11:38:26 +03:00
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 ───────────────────────────────────────────────────────────
2026-04-13 09:46:02 +03:00
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)
);
}
2026-04-15 11:38:26 +03:00
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' };
}
}
2026-04-13 09:46:02 +03:00
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');
2026-04-15 11:38:26 +03:00
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;
2026-04-13 09:46:02 +03:00
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>` +
2026-04-15 11:38:26 +03:00
(vpnCount > 0 ? `<span class="cl-stat cl-stat--vpn">${vpnCount} через VPN</span>` : '') +
(disabledCount > 0 ? `<span class="cl-stat cl-stat--blocked">${disabledCount} отключено</span>` : '');
2026-04-13 09:46:02 +03:00
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');
2026-04-15 11:38:26 +03:00
const effectivePolicy = c.policy || (c.blocked ? 'disabled' : '');
if (effectivePolicy === 'disabled') tr.classList.add('row-blocked');
if (effectivePolicy === 'vpn') tr.classList.add('row-vpn');
2026-04-13 09:46:02 +03:00
const activity = fmtLastActive(c);
2026-04-15 11:38:26 +03:00
const pl = policyLabel(effectivePolicy);
2026-04-13 09:46:02 +03:00
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>`;
tr.innerHTML = `
<td class="col-status">
<span class="state-dot ${online ? 'up' : 'down'}"
title="${online ? 'онлайн' : 'офлайн'}"></span>
</td>
2026-04-15 11:38:26 +03:00
<td class="col-host">${hostname}</td>
2026-04-13 09:46:02 +03:00
<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>
2026-04-15 11:38:26 +03:00
<td class="col-policy"><span class="${pl.cls}">${pl.text}</span></td>
2026-04-13 09:46:02 +03:00
<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;
}
2026-04-15 11:38:26 +03:00
// ── Modal ──────────────────────────────────────────────────────────────────
2026-04-13 09:46:02 +03:00
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 || '—';
2026-04-15 11:38:26 +03:00
// Set policy selector — empty string means "use default"
const effectivePolicy = c.policy || '';
modalSelectedPolicy = effectivePolicy;
updateModalPolicySelector(effectivePolicy);
2026-04-13 09:46:02 +03:00
modal.classList.remove('hidden');
document.getElementById('modalHostname').focus();
}
2026-04-15 11:38:26 +03:00
function updateModalPolicySelector(val) {
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === val);
});
const hint = document.getElementById('modalPolicyHint');
const descriptions = {
disabled: 'Устройство заблокировано — нет доступа в интернет',
direct: 'Трафик идёт напрямую через NAT, минуя VPN',
vpn: 'Трафик перенаправляется через Mihomo (tproxy)',
'': 'Используется политика по умолчанию',
};
hint.textContent = descriptions[val] ?? '';
2026-04-13 09:46:02 +03:00
}
2026-04-15 11:38:26 +03:00
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
btn.addEventListener('click', () => {
modalSelectedPolicy = btn.dataset.val;
updateModalPolicySelector(btn.dataset.val);
});
});
2026-04-13 09:46:02 +03:00
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 staticIP = document.getElementById('modalStaticIP').value.trim();
2026-04-15 11:38:26 +03:00
const policy = modalSelectedPolicy; // "disabled" | "direct" | "vpn" | ""
2026-04-13 09:46:02 +03:00
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' },
2026-04-15 11:38:26 +03:00
body: JSON.stringify({
hostname,
blocked: policy === 'disabled',
static_ip: staticIP,
policy,
}),
2026-04-13 09:46:02 +03:00
});
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;
}
}
2026-04-15 11:38:26 +03:00
// ── Utilities ──────────────────────────────────────────────────────────────
2026-04-13 09:46:02 +03:00
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);
}
2026-04-15 11:38:26 +03:00
// ── Event wiring ───────────────────────────────────────────────────────────
2026-04-13 09:46:02 +03:00
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.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
setInterval(loadClients, 10000);
2026-04-15 11:38:26 +03:00
loadDefaultPolicy();
loadClients();