'use strict'; const state = { interfaces: [], pending: [], configModal: null, configModalParent: null, nat: null, }; async function api(method, path, body) { const opts = { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, }; const res = await fetch(path, opts); const json = await res.json(); if (!res.ok || !json.success) { throw new Error(json.error || `HTTP ${res.status}`); } return json.data; } const get = (path) => api('GET', path); const post = (path, body) => api('POST', path, body); const del = (path) => api('DELETE', path); function isVLAN(name) { return /\.\d+$/.test(name); } function vlanParent(name) { return name.replace(/\.\d+$/, ''); } function vlanId(name) { const m = name.match(/\.(\d+)$/); return m ? parseInt(m[1]) : 0; } function fmtBytes(n) { if (n === undefined || n === null) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } function stateClass(s) { if (s === 'up') return 'up'; if (s === 'down') return 'down'; return 'unknown'; } function modeLabel(m) { return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?'); } const ICON = { pencil: ` `, restart: ` `, trash: ` `, plus: ` `, }; function ipClass(ip) { if (!ip) return ''; const p = ip.split('.').map(Number); if (p.length !== 4 || p.some(isNaN)) return ''; if (p[0] >= 1 && p[0] <= 126) return 'A'; if (p[0] === 127) return 'loopback'; if (p[0] >= 128 && p[0] <= 191) return 'B'; if (p[0] >= 192 && p[0] <= 223) return 'C'; return ''; } function guessMask(ip) { const c = ipClass(ip); switch (c) { case 'A': return '255.0.0.0'; case 'B': return '255.255.0.0'; case 'C': return '255.255.255.0'; default: return ''; } } function maskToCIDR(mask) { if (!mask) return ''; if (/^\d+$/.test(mask)) return '/' + mask; const parts = mask.split('.').map(Number); if (parts.length !== 4 || parts.some(isNaN)) return '/' + mask; const bits = parts.map(p => (p >>> 0).toString(2).padStart(8, '0')).join(''); const cidr = bits.split('0')[0].length; if (cidr > 0 && cidr <= 32) return '/' + cidr; return '/' + mask; } function renderAll() { const grid = document.getElementById('ifaceGrid'); grid.innerHTML = ''; const vlansByParent = {}; const physicals = []; for (const iface of state.interfaces) { if (isVLAN(iface.name)) { const p = vlanParent(iface.name); if (!vlansByParent[p]) vlansByParent[p] = []; vlansByParent[p].push(iface); } else { physicals.push(iface); } } const wrap = document.createElement('div'); wrap.className = 'iface-table-wrap'; const table = document.createElement('table'); table.className = 'iface-table'; table.innerHTML = ` Интерфейс Тип IPv4 Шлюз Режим Трафик Действия `; wrap.appendChild(table); grid.appendChild(wrap); const tbody = document.getElementById('ifaceTableBody'); for (const iface of physicals) { const isLo = iface.name === 'lo' || iface.mode === 'loopback'; if (isLo) continue; const vlans = vlansByParent[iface.name] || []; tbody.appendChild(buildPhysicalRow(iface, vlans)); } document.getElementById('loading').classList.add('hidden'); grid.classList.remove('hidden'); renderPendingBanner(); } function buildPhysicalRow(iface, vlans) { const hasPending = state.pending.includes(iface.name); const sc = stateClass(iface.state); const isUp = iface.state === 'up'; const isWAN = iface.type === 'wan'; const label = iface.label || ''; const ipDisplay = iface.ipv4 ? iface.ipv4 + (iface.ipv4_mask ? maskToCIDR(iface.ipv4_mask) : '') : ''; const gwDisplay = iface.gateway ? iface.gateway : ''; const trafficDisplay = `
${fmtBytes(iface.rx_bytes)} ${fmtBytes(iface.tx_bytes)}
`; const nameCell = label ? `
${label}${iface.name}
` : `${iface.name}`; const typeBadge = isWAN ? 'WAN' : 'LAN'; const tr = document.createElement('tr'); tr.className = 'iface-row' + (hasPending ? ' has-pending' : '') + (isWAN ? ' row-wan' : ''); tr.dataset.name = iface.name; tr.innerHTML = `
${nameCell} ${hasPending ? 'несохранено' : ''}
${typeBadge} ${ipDisplay} ${gwDisplay} ${modeLabel(iface.mode)} ${trafficDisplay}
`; const frag = document.createDocumentFragment(); frag.appendChild(tr); for (const v of vlans) { frag.appendChild(buildVLANRow(v, iface.name)); } return frag; } function buildVLANRow(v, parentName) { const sc = stateClass(v.state); const hasPending = state.pending.includes(v.name); const isUp = v.state === 'up'; const label = v.label || ''; const isWAN = v.type === 'wan'; const ip = v.ipv4 ? v.ipv4 + (v.ipv4_mask ? maskToCIDR(v.ipv4_mask) : '') : ''; const gwDisplay = v.gateway ? v.gateway : ''; const typeBadge = isWAN ? 'WAN' : 'LAN'; const nameCell = label ? `
${label}${v.name}
` : `${v.name}`; const tr = document.createElement('tr'); tr.className = 'iface-row iface-row-vlan' + (hasPending ? ' has-pending' : ''); tr.dataset.name = v.name; tr.dataset.parent = parentName; tr.innerHTML = `
VLAN ${vlanId(v.name)} ${nameCell} ${hasPending ? 'несохранено' : ''}
${typeBadge} ${ip} ${gwDisplay} ${modeLabel(v.mode)}
`; return tr; } function renderPendingBanner() { const banner = document.getElementById('pendingBanner'); const list = document.getElementById('pendingList'); if (state.pending.length > 0) { list.textContent = state.pending.join(', '); banner.classList.remove('hidden'); } else { banner.classList.add('hidden'); } } async function loadAll() { try { const [ifaces, pending] = await Promise.all([ get('/api/interfaces'), get('/api/pending'), ]); state.interfaces = ifaces || []; state.pending = pending || []; renderAll(); } catch (e) { showToast('Ошибка загрузки: ' + e.message, 'error'); } } async function doAction(name, action) { if (action === 'delete') { if (!confirm(`Удалить VLAN ${name}?`)) return; try { await post(`/api/interfaces/${name}/delete`); showToast(`${name}: удалён`, 'success'); await loadAll(); } catch (e) { showToast(`${name} delete: ${e.message}`, 'error'); } return; } try { await post(`/api/interfaces/${name}/${action}`); showToast(`${name}: ${action} выполнено`, 'success'); await loadAll(); } catch (e) { showToast(`${name} ${action}: ${e.message}`, 'error'); await loadAll(); } } async function openConfig(name) { state.configModal = name; state.configModalParent = null; document.getElementById('modalTitle').textContent = `Настройка: ${name}`; const vlanSection = document.getElementById('vlanIdSection'); const vlanInput = document.getElementById('cfgVLANId'); if (isVLAN(name)) { vlanSection.classList.remove('hidden'); vlanInput.value = vlanId(name); vlanInput.readOnly = true; } else { vlanSection.classList.add('hidden'); vlanInput.readOnly = false; } try { const [configData, natData] = await Promise.all([ get(`/api/config/${name}`), get('/api/nat').catch(() => null), ]); if (natData) state.nat = natData; let currentType = configData.type || 'lan'; if (isVLAN(name)) currentType = 'lan'; fillForm(configData.config, configData.pending, name, configData.label || '', currentType, isVLAN(name)); document.getElementById('modal').classList.remove('hidden'); } catch (e) { showToast('Ошибка загрузки конфига: ' + e.message, 'error'); } } async function openNewVLAN(parentName) { const parentIface = state.interfaces.find(i => i.name === parentName); if (parentIface && parentIface.type === 'wan') { showToast('Нельзя добавить VLAN на WAN-интерфейс', 'error'); return; } state.configModal = null; state.configModalParent = parentName; document.getElementById('modalTitle').textContent = `Новый VLAN на ${parentName}`; const vlanSection = document.getElementById('vlanIdSection'); const vlanInput = document.getElementById('cfgVLANId'); vlanSection.classList.remove('hidden'); vlanInput.readOnly = false; vlanInput.value = ''; try { const natData = await get('/api/nat').catch(() => null); if (natData) state.nat = natData; } catch (_) {} fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true); document.getElementById('modal').classList.remove('hidden'); } function fillForm(cfg, pending, name, label = '', ifaceType = 'lan', forceLAN = false) { document.getElementById('cfgLabel').value = label; document.getElementById('cfgAuto').checked = !!cfg.auto; document.getElementById('cfgAddress').value = cfg.address || ''; document.getElementById('cfgNetmask').value = cfg.netmask || ''; document.getElementById('cfgGateway').value = cfg.gateway || ''; document.getElementById('cfgDNS').value = (cfg.dns || []).join(' '); setType(ifaceType, forceLAN); const mode = cfg.mode === 'static' ? 'static' : 'dhcp'; setMode(mode); if (pending && name) { document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`; } } function setType(t, forceLAN = false) { document.querySelectorAll('#typeSwitch .seg-btn').forEach(b => { b.classList.toggle('active', b.dataset.type === t); }); const typeRow = document.getElementById('typeSwitch').closest('.form-row'); typeRow.classList.toggle('hidden', forceLAN); updateTypeVisibility(t); } function currentType() { return document.querySelector('#typeSwitch .seg-btn.active')?.dataset.type ?? 'lan'; } function updateTypeVisibility(type) { const modeRow = document.getElementById('modeRow'); const gatewayRow = document.getElementById('gatewayRow'); const dnsRow = document.getElementById('dnsRow'); const natSection = document.getElementById('natSection'); if (type === 'lan') { modeRow.classList.add('hidden'); setMode('static'); gatewayRow.classList.add('hidden'); dnsRow.classList.add('hidden'); natSection.classList.remove('hidden'); } else { modeRow.classList.remove('hidden'); gatewayRow.classList.remove('hidden'); dnsRow.classList.remove('hidden'); natSection.classList.add('hidden'); } updateNATSection(type); } function updateNATSection(type) { if (type === 'lan') { const natNotInstalled = document.getElementById('natNotInstalled'); const cfgNAT = document.getElementById('cfgNAT'); const name = state.configModal; const natInstalled = state.nat?.installed !== false; cfgNAT.disabled = !natInstalled; natNotInstalled.classList.toggle('hidden', natInstalled); cfgNAT.checked = !!(state.nat?.interfaces || []).includes(name); } } function setMode(mode) { document.querySelectorAll('#modeSwitch .seg-btn').forEach(b => { b.classList.toggle('active', b.dataset.mode === mode); }); document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static'); } function currentMode() { return document.querySelector('#modeSwitch .seg-btn.active')?.dataset.mode ?? 'dhcp'; } function closeModal() { document.getElementById('modal').classList.add('hidden'); document.getElementById('configForm').reset(); state.configModal = null; state.configModalParent = null; } async function saveConfig() { let name = state.configModal; if (!name) { const parent = state.configModalParent; const id = parseInt(document.getElementById('cfgVLANId').value); if (!parent) return; if (!id || id < 1 || id > 4094) { showToast('Укажите корректный VLAN ID (1–4094)', 'error'); return; } name = `${parent}.${id}`; if (state.interfaces.find(i => i.name === name)) { showToast(`VLAN ${name} уже существует`, 'error'); return; } } const type = currentType(); const mode = type === 'lan' ? 'static' : currentMode(); const cfg = { name, label: document.getElementById('cfgLabel').value.trim(), type, auto: document.getElementById('cfgAuto').checked, mode, address: document.getElementById('cfgAddress').value.trim(), netmask: document.getElementById('cfgNetmask').value.trim(), gateway: type === 'wan' ? document.getElementById('cfgGateway').value.trim() : '', dns: type === 'wan' ? document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean) : [], extra: {}, }; if (mode === 'static' && !cfg.address) { showToast('Укажите IP-адрес', 'error'); return; } if (type === 'wan' && mode === 'static' && !cfg.netmask) { showToast('Укажите маску сети', 'error'); return; } try { await post(`/api/config/${name}`, cfg); const natSection = document.getElementById('natSection'); if (!natSection.classList.contains('hidden') && state.nat?.installed !== false) { const natEnabled = document.getElementById('cfgNAT').checked; const current = state.nat?.interfaces || []; const updated = natEnabled ? [...new Set([...current, name])] : current.filter(i => i !== name); await post('/api/nat', { interfaces: updated }); if (state.nat) state.nat.interfaces = updated; } showToast(`${name}: настройки сохранены (ожидают применения)`, 'info'); closeModal(); await loadAll(); } catch (e) { showToast('Ошибка сохранения: ' + e.message, 'error'); } } async function applyAll() { const btn = document.getElementById('applyBtn'); btn.disabled = true; btn.textContent = 'Применяю...'; try { await post('/api/apply'); showToast('Настройки применены', 'success'); await loadAll(); } catch (e) { showToast('Ошибка применения: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Применить'; } } async function discardAll() { for (const name of [...state.pending]) { try { await del(`/api/config/${name}`); } catch (_) {} } state.pending = []; renderPendingBanner(); showToast('Изменения отменены', 'info'); await loadAll(); } 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); } document.getElementById('applyBtn').addEventListener('click', applyAll); document.getElementById('discardAllBtn').addEventListener('click', discardAll); document.getElementById('ifaceGrid').addEventListener('click', e => { const btn = e.target.closest('[data-action]'); if (!btn) return; if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return; const { action, iface } = btn.dataset; if (!action || !iface) return; if (action === 'config') { openConfig(iface); } else if (action === 'addvlan') { openNewVLAN(iface); } else { doAction(iface, action); } }); document.getElementById('ifaceGrid').addEventListener('change', e => { const input = e.target.closest('input[data-action="toggle"]'); if (!input) return; doAction(input.dataset.iface, input.checked ? 'up' : 'down'); }); document.getElementById('closeModal').addEventListener('click', closeModal); document.getElementById('cancelConfigBtn').addEventListener('click', closeModal); document.getElementById('modalBackdrop').addEventListener('click', closeModal); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); document.getElementById('typeSwitch').addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (btn) setType(btn.dataset.type); }); document.getElementById('modeSwitch').addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (btn) setMode(btn.dataset.mode); }); document.getElementById('cfgNetmask').addEventListener('focus', () => { const maskInput = document.getElementById('cfgNetmask'); if (maskInput.value) return; const addr = document.getElementById('cfgAddress').value.trim(); if (!addr) return; const m = guessMask(addr); if (m) maskInput.value = m; }); document.getElementById('saveConfigBtn').addEventListener('click', saveConfig); document.getElementById('configForm').addEventListener('submit', e => { e.preventDefault(); saveConfig(); }); setInterval(loadAll, 10000); (async () => { await loadAll(); })();