'use strict'; // ── State ──────────────────────────────────────────────────────────────────── const state = { interfaces: [], // latest data from /api/interfaces pending: [], // interface names with pending config configModal: null, // name of interface being configured (null = new VLAN) configModalParent: null, // parent interface when creating a new VLAN nat: null, // {installed, interfaces} from /api/nat }; // ── API helpers ────────────────────────────────────────────────────────────── 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); // ── VLAN helpers ───────────────────────────────────────────────────────────── 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; } // ── Format helpers ─────────────────────────────────────────────────────────── 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 || '?'); } // ── SVG icons ──────────────────────────────────────────────────────────────── const ICON = { pencil: ` `, restart: ` `, trash: ` `, plus: ` `, }; // ── Render ─────────────────────────────────────────────────────────────────── function renderAll() { const grid = document.getElementById('ifaceGrid'); grid.innerHTML = ''; // Group VLANs by parent 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); } } for (const iface of physicals) { const vlans = vlansByParent[iface.name] || []; grid.appendChild(buildCard(iface, vlans)); } document.getElementById('loading').classList.add('hidden'); grid.classList.remove('hidden'); renderPendingBanner(); } function buildCard(iface, vlans) { const hasPending = state.pending.includes(iface.name); const sc = stateClass(iface.state); const isLo = iface.name === 'lo' || iface.mode === 'loopback'; const isUp = iface.state === 'up'; const label = iface.label || ''; const card = document.createElement('div'); card.className = 'iface-card' + (hasPending ? ' has-pending' : ''); card.dataset.name = iface.name; const ipv6lines = (iface.ipv6 || []).map(a => `
IPv6${a}
` ).join(''); const nameBlock = label ? `
${label} ${iface.name}
` : `${iface.name}`; card.innerHTML = `
${nameBlock} ${hasPending ? 'несохранено' : ''}
${modeLabel(iface.mode)}
IPv4 ${iface.ipv4 ? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '') : ''}
${ipv6lines || `
IPv6
`}
Шлюз ${iface.gateway || ''}
↓ RX ${fmtBytes(iface.rx_bytes)}
↑ TX ${fmtBytes(iface.tx_bytes)}
Пакеты ${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0}
${!isLo ? ` ` : ''}
${!isLo ? buildVLANSection(iface.name, vlans) : ''} `; return card; } function buildVLANSection(parentName, vlans) { const rows = vlans.map(v => { const sc = stateClass(v.state); const hasPending = state.pending.includes(v.name); const isUp = v.state === 'up'; const label = v.label || ''; const ip = v.ipv4 ? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '') : ''; return `
${label ? `
${label}${v.name}
` : `${v.name}`} VLAN ${vlanId(v.name)} ${modeLabel(v.mode)} ${hasPending ? 'несохранено' : ''}
${ip}
`; }).join(''); const empty = vlans.length === 0 ? `
Нет тегированных VLAN
` : ''; return `
VLAN
${rows} ${empty}
`; } 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'); } } // ── Data loading ───────────────────────────────────────────────────────────── 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'); } } // ── Interface actions ───────────────────────────────────────────────────────── 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(); // refresh to restore correct toggle state } } // ── Config modal ────────────────────────────────────────────────────────────── async function openConfig(name) { state.configModal = name; state.configModalParent = null; document.getElementById('modalTitle').textContent = `Настройка: ${name}`; // Show/hide VLAN ID field 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; fillForm(configData.config, configData.pending, name, configData.label || ''); document.getElementById('modal').classList.remove('hidden'); } catch (e) { showToast('Ошибка загрузки конфига: ' + e.message, 'error'); } } async function openNewVLAN(parentName) { 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, '', ''); document.getElementById('modal').classList.remove('hidden'); } function fillForm(cfg, pending, name, label = '') { 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(' '); const mode = cfg.mode === 'static' ? 'static' : 'dhcp'; setMode(mode); if (pending && name) { document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`; } // NAT section — show for all non-loopback interfaces const natSection = document.getElementById('natSection'); const natNotInstalled = document.getElementById('natNotInstalled'); const cfgNAT = document.getElementById('cfgNAT'); if (cfg.mode === 'loopback' || name === 'lo') { natSection.classList.add('hidden'); } else { natSection.classList.remove('hidden'); 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('.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('.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) { // New VLAN — build name from parent + VLAN ID 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}`; // Check for duplicate if (state.interfaces.find(i => i.name === name)) { showToast(`VLAN ${name} уже существует`, 'error'); return; } } const mode = currentMode(); const cfg = { name, label: document.getElementById('cfgLabel').value.trim(), auto: document.getElementById('cfgAuto').checked, mode, address: document.getElementById('cfgAddress').value.trim(), netmask: document.getElementById('cfgNetmask').value.trim(), gateway: document.getElementById('cfgGateway').value.trim(), dns: document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean), extra: {}, }; if (mode === 'static' && !cfg.address) { showToast('Укажите IP-адрес', 'error'); return; } try { await post(`/api/config/${name}`, cfg); // Save NAT setting if section is visible 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'); } } // ── Apply / discard ─────────────────────────────────────────────────────────── 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(); } // ── 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('refreshBtn').addEventListener('click', loadAll); document.getElementById('applyBtn').addEventListener('click', applyAll); document.getElementById('discardAllBtn').addEventListener('click', discardAll); // Card button clicks (delegated) document.getElementById('ifaceGrid').addEventListener('click', e => { const btn = e.target.closest('[data-action]'); if (!btn) return; // Don't handle toggle inputs here (handled by 'change' below) 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); } }); // Toggle switch (on/off) — delegated change event 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'); }); // Modal close 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(); }); // Mode switcher document.getElementById('modeSwitch').addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (btn) setMode(btn.dataset.mode); }); // Save config document.getElementById('saveConfigBtn').addEventListener('click', saveConfig); document.getElementById('configForm').addEventListener('submit', e => { e.preventDefault(); saveConfig(); }); // Auto-refresh every 10 seconds setInterval(loadAll, 10000); // ── Init ────────────────────────────────────────────────────────────────────── (async () => { await loadAll(); })();