Files
alpine-router/public/clients.js
2026-04-15 11:38:26 +03:00

402 lines
15 KiB
JavaScript
Raw Permalink 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;
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 ───────────────────────────────────────────────────────────
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 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' };
}
}
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 disabledCount = allClients.filter(c => c.policy === 'disabled' || (c.policy === '' && c.blocked)).length;
const vpnCount = allClients.filter(c => c.policy === 'vpn').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>` +
(vpnCount > 0 ? `<span class="cl-stat cl-stat--vpn">${vpnCount} через VPN</span>` : '') +
(disabledCount > 0 ? `<span class="cl-stat cl-stat--blocked">${disabledCount} отключено</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');
const effectivePolicy = c.policy || (c.blocked ? 'disabled' : '');
if (effectivePolicy === 'disabled') tr.classList.add('row-blocked');
if (effectivePolicy === 'vpn') tr.classList.add('row-vpn');
const activity = fmtLastActive(c);
const pl = policyLabel(effectivePolicy);
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>
<td class="col-host">${hostname}</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-policy"><span class="${pl.cls}">${pl.text}</span></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;
}
// ── Modal ──────────────────────────────────────────────────────────────────
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 || '—';
// Set policy selector — empty string means "use default"
const effectivePolicy = c.policy || '';
modalSelectedPolicy = effectivePolicy;
updateModalPolicySelector(effectivePolicy);
modal.classList.remove('hidden');
document.getElementById('modalHostname').focus();
}
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] ?? '';
}
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
btn.addEventListener('click', () => {
modalSelectedPolicy = btn.dataset.val;
updateModalPolicySelector(btn.dataset.val);
});
});
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();
const policy = modalSelectedPolicy; // "disabled" | "direct" | "vpn" | ""
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: policy === 'disabled',
static_ip: staticIP,
policy,
}),
});
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;
}
}
// ── Utilities ──────────────────────────────────────────────────────────────
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);
}
// ── Event wiring ───────────────────────────────────────────────────────────
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);
loadDefaultPolicy();
loadClients();