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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|