344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
|
|
'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();
|