'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.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('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();