Files
alpine-router/public/dhcp.js

344 lines
14 KiB
JavaScript
Raw Normal View History

2026-04-13 09:46:02 +03:00
'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 = `
<div class="pool-header">
<div class="pool-iface">
<span class="state-dot ${iface.ipv4 ? 'up' : 'unknown'}"></span>
<span class="pool-iface-name">${iface.name}</span>
${isWAN ? '<span class="tag-gw">WAN</span>' : ''}
${(!isWAN && enabled) ? '<span class="tag-active">DHCP активен</span>' : ''}
</div>
${!isWAN ? `<button class="btn btn-primary btn-sm" data-edit="${iface.name}">
${pool ? '⚙ Изменить' : '+ Добавить пул'}
</button>` : ''}
</div>
<div class="pool-info">
${iface.ipv4
? `<div class="info-row"><span class="info-label">IP</span>
<span class="info-val">${iface.ipv4}${iface.ipv4_mask ? ' / ' + iface.ipv4_mask : ''}</span></div>`
: `<div class="info-row"><span class="info-val none">нет IP-адреса</span></div>`}
${isWAN
? `<div class="info-row"><span class="info-val muted">Интерфейс имеет шлюз — DHCP не раздаётся</span></div>`
: pool
? `
<div class="info-row">
<span class="info-label">Подсеть</span>
<span class="info-val">${pool.subnet} / ${pool.netmask || '?'}</span>
</div>
<div class="info-row">
<span class="info-label">Диапазон</span>
<span class="info-val">${pool.range_start || '—'} ${pool.range_end || '—'}</span>
</div>
<div class="info-row">
<span class="info-label">Шлюз</span>
<span class="info-val">${pool.router || '—'}</span>
</div>
<div class="info-row">
<span class="info-label">DNS</span>
<span class="info-val">${(pool.dns || []).join(', ') || '—'}</span>
</div>
<div class="info-row">
<span class="info-label">Аренда</span>
<span class="info-val">${fmtLease(pool.lease_time)}</span>
</div>
`
: `<div class="info-row"><span class="info-val muted">Пул не настроен</span></div>`
}
</div>
${(!isWAN && pool) ? `
<div class="pool-footer">
<label class="checkbox-label" style="font-size:.83rem">
<input type="checkbox" class="pool-enabled-chk" data-iface="${iface.name}" ${enabled ? 'checked' : ''}>
<span>Активен</span>
</label>
<button class="btn btn-danger btn-sm" data-remove="${iface.name}" style="margin-left:auto">Удалить пул</button>
</div>` : ''}
`;
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();