14.04.2026 Update
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user