Files
alpine-router/public/dhcp.js
2026-04-15 11:38:26 +03:00

344 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();