'use strict'; // ── State ───────────────────────────────────────────────────────────────────── const state = { installed: false, running: false, config: { enabled: false, pools: [] }, ifaces: [], // all non-lo interfaces from the API dirty: false, editIface: null, // interface name being edited in modal }; // ── API ─────────────────────────────────────────────────────────────────────── async function api(method, path, body) { const res = await fetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, }); const json = await res.json(); if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); return json.data; } // ── Helpers ─────────────────────────────────────────────────────────────────── function fmtLease(sec) { if (!sec || sec <= 0) return '24 ч'; if (sec < 3600) return `${sec} с`; if (sec < 86400) return `${(sec/3600).toFixed(0)} ч`; return `${(sec/86400).toFixed(0)} д`; } function poolForIface(name) { return state.config.pools.find(p => p.interface === name) || null; } // ── Render ──────────────────────────────────────────────────────────────────── function render() { // Not-installed banner document.getElementById('notInstalledBanner').classList.toggle('hidden', state.installed); // Status bar const svcBadge = document.getElementById('svcStatus'); svcBadge.textContent = state.running ? 'работает' : 'остановлен'; svcBadge.className = `svc-badge ${state.running ? 'running' : 'stopped'}`; document.getElementById('enableToggle').checked = state.config.enabled; document.getElementById('applyBtn').disabled = !state.installed || !state.dirty; // Pools grid const grid = document.getElementById('poolsGrid'); const loading = document.getElementById('poolsLoading'); const noIface = document.getElementById('noIfaces'); loading.classList.add('hidden'); // interfaces eligible for DHCP: no gateway const eligible = state.ifaces.filter(i => !i.has_gateway); if (eligible.length === 0) { grid.classList.add('hidden'); noIface.classList.remove('hidden'); return; } noIface.classList.add('hidden'); grid.classList.remove('hidden'); grid.innerHTML = ''; // Show ALL interfaces (both eligible and WAN), so the user sees the full picture state.ifaces.forEach(iface => { grid.appendChild(buildPoolCard(iface)); }); } function buildPoolCard(iface) { const pool = poolForIface(iface.name); const isWAN = iface.has_gateway; const enabled = pool?.enabled ?? false; const card = document.createElement('div'); card.className = 'pool-card' + (isWAN ? ' pool-card--wan' : '') + (enabled ? ' pool-card--active' : ''); card.innerHTML = `
${iface.name} ${isWAN ? 'WAN' : ''} ${(!isWAN && enabled) ? 'DHCP активен' : ''}
${!isWAN ? `` : ''}
${iface.ipv4 ? `
IP ${iface.ipv4}${iface.ipv4_mask ? ' / ' + iface.ipv4_mask : ''}
` : `
нет IP-адреса
`} ${isWAN ? `
Интерфейс имеет шлюз — DHCP не раздаётся
` : pool ? `
Подсеть ${pool.subnet} / ${pool.netmask || '?'}
Диапазон ${pool.range_start || '—'} — ${pool.range_end || '—'}
Шлюз ${pool.router || '—'}
DNS ${(pool.dns || []).join(', ') || '—'}
Аренда ${fmtLease(pool.lease_time)}
` : `
Пул не настроен
` }
${(!isWAN && pool) ? ` ` : ''} `; return card; } // ── Load data ───────────────────────────────────────────────────────────────── async function loadAll() { try { const [status, data] = await Promise.all([ api('GET', '/api/dhcp/status'), api('GET', '/api/dhcp/config'), ]); state.installed = status.installed; state.running = status.running; state.config = data.config || { enabled: false, pools: [] }; state.ifaces = data.interfaces || []; state.dirty = false; render(); } catch (e) { showToast('Ошибка загрузки: ' + e.message, 'error'); } } // ── Apply ───────────────────────────────────────────────────────────────────── async function saveConfig() { try { await api('POST', '/api/dhcp/config', state.config); state.dirty = false; render(); } catch (e) { showToast('Ошибка сохранения: ' + e.message, 'error'); throw e; } } async function applyConfig() { const btn = document.getElementById('applyBtn'); btn.disabled = true; btn.textContent = 'Применяю...'; try { await saveConfig(); await api('POST', '/api/dhcp/apply'); showToast('DHCP конфигурация применена', 'success'); await loadAll(); } catch (e) { showToast('Ошибка применения: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Применить'; } } // ── Pool modal ──────────────────────────────────────────────────────────────── function openPoolModal(ifaceName) { state.editIface = ifaceName; document.getElementById('poolModalTitle').textContent = `Пул для ${ifaceName}`; document.getElementById('pIface').value = ifaceName; const existing = poolForIface(ifaceName); const iface = state.ifaces.find(i => i.name === ifaceName); // Defaults: auto-fill subnet/netmask/router from interface IP if possible if (existing) { document.getElementById('pEnabled').checked = existing.enabled; document.getElementById('pSubnet').value = existing.subnet || ''; document.getElementById('pNetmask').value = existing.netmask || ''; document.getElementById('pRangeStart').value = existing.range_start || ''; document.getElementById('pRangeEnd').value = existing.range_end || ''; document.getElementById('pRouter').value = existing.router || ''; document.getElementById('pDNS').value = (existing.dns || []).join(' '); document.getElementById('pLease').value = existing.lease_time || 86400; } else { document.getElementById('poolForm').reset(); document.getElementById('pEnabled').checked = true; // auto-suggest subnet and router from interface address if (iface?.ipv4) { const parts = iface.ipv4.split('.'); const subnet = parts.slice(0, 3).join('.') + '.0'; document.getElementById('pSubnet').value = subnet; document.getElementById('pRouter').value = iface.ipv4; const rangeBase = parts.slice(0, 3).join('.'); document.getElementById('pRangeStart').value = rangeBase + '.100'; document.getElementById('pRangeEnd').value = rangeBase + '.200'; } if (iface?.ipv4_mask) { document.getElementById('pNetmask').value = iface.ipv4_mask; } document.getElementById('pLease').value = 86400; } document.getElementById('poolModal').classList.remove('hidden'); } function closePoolModal() { document.getElementById('poolModal').classList.add('hidden'); state.editIface = null; } function savePool() { const ifaceName = document.getElementById('pIface').value; if (!ifaceName) return; const pool = { interface: ifaceName, enabled: document.getElementById('pEnabled').checked, subnet: document.getElementById('pSubnet').value.trim(), netmask: document.getElementById('pNetmask').value.trim(), range_start: document.getElementById('pRangeStart').value.trim(), range_end: document.getElementById('pRangeEnd').value.trim(), router: document.getElementById('pRouter').value.trim(), dns: document.getElementById('pDNS').value.trim().split(/\s+/).filter(Boolean), lease_time: parseInt(document.getElementById('pLease').value, 10) || 86400, }; if (!pool.subnet || !pool.netmask) { showToast('Укажите подсеть и маску', 'error'); return; } // upsert const idx = state.config.pools.findIndex(p => p.interface === ifaceName); if (idx >= 0) { state.config.pools[idx] = pool; } else { state.config.pools.push(pool); } state.dirty = true; closePoolModal(); render(); showToast('Пул обновлён. Нажмите «Применить» чтобы активировать.', 'info'); } function removePool(ifaceName) { state.config.pools = state.config.pools.filter(p => p.interface !== ifaceName); state.dirty = true; render(); showToast('Пул удалён. Нажмите «Применить».', 'info'); } // ── Toast ───────────────────────────────────────────────────────────────────── 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('refreshBtn').addEventListener('click', loadAll); document.getElementById('enableToggle').addEventListener('change', e => { state.config.enabled = e.target.checked; state.dirty = true; render(); }); document.getElementById('applyBtn').addEventListener('click', applyConfig); // Delegated: edit/remove pool buttons + enabled checkboxes document.getElementById('poolsGrid').addEventListener('click', e => { const editBtn = e.target.closest('[data-edit]'); const removeBtn = e.target.closest('[data-remove]'); if (editBtn) openPoolModal(editBtn.dataset.edit); if (removeBtn) removePool(removeBtn.dataset.remove); }); document.getElementById('poolsGrid').addEventListener('change', e => { const chk = e.target.closest('.pool-enabled-chk'); if (!chk) return; const ifaceName = chk.dataset.iface; const pool = poolForIface(ifaceName); if (pool) { pool.enabled = chk.checked; state.dirty = true; render(); } }); // Pool modal document.getElementById('closePoolModal').addEventListener('click', closePoolModal); document.getElementById('cancelPoolBtn').addEventListener('click', closePoolModal); document.getElementById('poolModalBackdrop').addEventListener('click', closePoolModal); document.getElementById('savePoolBtn').addEventListener('click', savePool); document.getElementById('poolForm').addEventListener('submit', e => { e.preventDefault(); savePool(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closePoolModal(); }); // ── Init ────────────────────────────────────────────────────────────────────── loadAll();