14.04.2026 Update

This commit is contained in:
2026-04-15 11:38:26 +03:00
parent 6aa0349f5d
commit f50d79fab3
45 changed files with 5645 additions and 751 deletions

View File

@@ -3,6 +3,72 @@
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 {
@@ -75,6 +141,15 @@ function matchesSearch(c, q) {
);
}
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');
@@ -84,15 +159,17 @@ function render() {
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;
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>` +
(blockedCount > 0 ? `<span class="cl-stat cl-stat--blocked">${blockedCount} заблокировано</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));
@@ -123,9 +200,13 @@ function buildRow(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 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>'
@@ -151,20 +232,17 @@ function buildRow(c) {
? `<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-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>
@@ -174,6 +252,8 @@ function buildRow(c) {
return tr;
}
// ── Modal ──────────────────────────────────────────────────────────────────
function openModal(c) {
editingClient = { ...c };
const modal = document.getElementById('clientModal');
@@ -200,32 +280,36 @@ function openModal(c) {
document.getElementById('modalMAC').textContent = c.mac || '—';
document.getElementById('modalIface').textContent = c.interface || '—';
const blocked = document.getElementById('modalBlocked');
blocked.checked = !c.blocked;
updateBlockedToggle(c.blocked);
// 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 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 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;
@@ -241,8 +325,8 @@ async function saveClient() {
}
const hostname = document.getElementById('modalHostname').value.trim();
const isBlocked = !document.getElementById('modalBlocked').checked;
const staticIP = document.getElementById('modalStaticIP').value.trim();
const policy = modalSelectedPolicy; // "disabled" | "direct" | "vpn" | ""
const btn = document.getElementById('modalSave');
btn.disabled = true;
@@ -251,7 +335,12 @@ async function saveClient() {
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 })
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}`);
@@ -265,6 +354,8 @@ async function saveClient() {
}
}
// ── Utilities ──────────────────────────────────────────────────────────────
function ipToNum(ip) {
return (ip || '').split('.').reduce((acc, o) => acc * 256 + (+o || 0), 0);
}
@@ -284,7 +375,8 @@ function showToast(msg, type = 'info') {
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
}
document.getElementById('refreshBtn').addEventListener('click', loadClients);
// ── Event wiring ───────────────────────────────────────────────────────────
document.getElementById('clientsSearch').addEventListener('input', e => {
searchQuery = e.target.value.trim();
@@ -299,16 +391,11 @@ document.getElementById('clientForm').addEventListener('submit', e => {
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();
loadDefaultPolicy();
loadClients();