// Proxmox RouterOS Frontend Application Logic let state = { interfaces: [], vlans: [], systemStatus: null, vpnStatus: null, routes: [], activeTab: 'dashboard', fwSubTab: 'filter', bridgeSubTab: 'bridges', bridges: [], bridgePorts: [], bridgeVLANs: [], bridgeHosts: [], bridgeSummary: null, routesSubTab: 'routes', pppSubTab: 'secrets', eoipTunnels: [], pppSecrets: [], pppProfiles: [], pppActiveSessions: [], pppoeServer: null, pptpServer: null, l2tpServer: null, ipsecServer: null, ifaceFilter: 'all', currentModalIface: null, isPolling: true, expandedParents: new Set(['eth0']), pppoeData: null, wgData: null, ipsecData: null, l2tpData: null, pptpData: null, fwSummary: null, filterRules: [], natRules: [], mangleRules: [], addressLists: [], connections: [], policyRules: [], routingTables: [], routingFilters: [], filterChainSelected: 'all', activeToolsSubTab: 'ping', dhcpClients: [], dhcpSummary: null }; const API_BASE = '/api'; function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Initialize on page load document.addEventListener('DOMContentLoaded', () => { const hash = window.location.hash.replace('#', '') || 'dashboard'; navigate(hash); fetchData(); // Live polling every 2.5 seconds setInterval(() => { if (state.isPolling) { fetchData(false); } }, 2500); }); // Navigation Handler function navigate(tabName) { if (tabName === 'dynamic-routing' || tabName === 'routing') { navigate('bgp'); return; } if (tabName === 'vlans') { navigate('interfaces'); return; } state.activeTab = tabName; window.location.hash = tabName; // Update nav links styling document.querySelectorAll('.nav-link').forEach(link => { link.className = 'nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white'; }); const activeNav = document.getElementById(`nav-${tabName}`); if (activeNav) { activeNav.className = 'nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group bg-brand-500/10 text-brand-400 border border-brand-500/20'; } // Update page headers const titles = { dashboard: { title: 'Dashboard Overview', sub: 'Live router performance and network telemetry' }, interfaces: { title: 'Network Interfaces', sub: 'Interface Table View' }, 'dhcp-client': { title: 'DHCP Client', sub: 'DHCP Client List' }, bridge: { title: 'Bridge Interfaces', sub: 'IEEE 802.1D / 802.1Q VLAN-aware Layer 2 switching, RSTP & hardware forwarding (FDB)' }, vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' }, firewall: { title: 'Network Firewall', sub: 'Stateful packet inspection, NAT, traffic mangle, address lists & connection tracking' }, routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' }, bgp: { title: 'BGP Routing (/routing bgp)', sub: 'FRRouting BGP peering sessions, advertised networks, and BGP routing table (RIB)' }, ospf: { title: 'OSPF Routing (/routing ospf)', sub: 'FRRouting OSPFv2 link-state routing, dynamic neighbor adjacencies, and multi-area topology' }, 'routing-filters': { title: 'Routing Filters (/routing filter)', sub: 'Route filter chains, prefix matching, BGP community maps & policy-based path control' }, 'dynamic-routing': { title: 'BGP Routing (/routing bgp)', sub: 'FRRouting dynamic routing' }, ppp: { title: 'PPP Servers & Secrets', sub: 'Subscriber authentication, dynamic IP pools and VPN access concentrators' }, eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP (EoIP / GRETAP) Layer 2 encapsulation & transparent network bridging' }, pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' }, wireguard: { title: 'WireGuard VPN', sub: 'High-performance cryptographic Layer 3 tunnels with ChaCha20-Poly1305 key exchange' }, ipsec: { title: 'IPSec (IKEv2) strongSwan', sub: 'Enterprise Site-to-Site & Roadwarrior IPSec controller' }, l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' }, pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' }, tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' }, persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' } }; const header = titles[tabName] || titles.dashboard; const pTitle = document.getElementById('page-title'); if (pTitle) pTitle.innerText = header.title; const pSub = document.getElementById('page-subtitle'); if (pSub) pSub.innerText = header.sub; // Toggle sections dynamically for all section elements document.querySelectorAll('main section[id^="section-"]').forEach(el => { if (el.id === `section-${tabName}`) { el.classList.remove('hidden'); } else { el.classList.add('hidden'); } }); if (tabName === 'bridge') loadBridge(); if (tabName === 'firewall') loadFirewall(); if (tabName === 'routes') loadRoutes(); if (tabName === 'bgp') { loadDynamicRouting(); switchBgpSubTab(state.activeBgpSubTab || 'bgp-peers'); } if (tabName === 'ospf') { loadDynamicRouting(); switchOspfSubTab(state.activeOspfSubTab || 'ospf-ifaces'); } if (tabName === 'routing-filters') loadDynamicRouting(); if (tabName === 'dynamic-routing') loadDynamicRouting(); if (tabName === 'ppp') loadPPP(); if (tabName === 'eoip') loadEoIP(); if (tabName === 'persistence') loadDebianConfig(); if (tabName === 'pppoe') loadPPPoE(); if (tabName === 'wireguard') loadWireGuard(); if (tabName === 'ipsec') loadIPSec(); if (tabName === 'l2tp') loadL2TP(); if (tabName === 'pptp') loadPPTP(); if (tabName === 'tools') loadToolsView(); if (tabName === 'dhcp-client') loadDHCPClients(); } // Fetch all core system, network and VPN telemetry async function fetchData(isManual = false) { const spinner = document.getElementById('refresh-spinner'); if (isManual && spinner) spinner.classList.add('animate-spin'); try { const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes] = await Promise.all([ fetch(`${API_BASE}/system/status`), fetch(`${API_BASE}/interfaces`), fetch(`${API_BASE}/vlans`), fetch(`${API_BASE}/vpn/status`), fetch(`${API_BASE}/firewall/summary`), fetch(`${API_BASE}/routing/dynamic/summary`), fetch(`${API_BASE}/bridge/summary`), fetch(`${API_BASE}/dhcp/clients/summary`), fetch(`${API_BASE}/dhcp/clients`) ]); if (dhcpSumRes && dhcpSumRes.ok) { state.dhcpSummary = await dhcpSumRes.json(); const bDhcp = document.getElementById('badge-dhcp-client-count'); if (bDhcp) bDhcp.innerText = state.dhcpSummary?.total_clients || 0; } if (dhcpClientsRes && dhcpClientsRes.ok) { state.dhcpClients = await dhcpClientsRes.json(); } if (brSummaryRes.ok) { state.bridgeSummary = await brSummaryRes.json(); const bBridge = document.getElementById('badge-bridge-count'); if (bBridge) bBridge.innerText = state.bridgeSummary?.bridges_count || 0; const sBr = document.getElementById('bridge-stat-bridges'); if (sBr) sBr.innerText = state.bridgeSummary?.bridges_count || 0; const sPorts = document.getElementById('bridge-stat-ports'); if (sPorts) sPorts.innerText = state.bridgeSummary?.ports_count || 0; const sHosts = document.getElementById('bridge-stat-hosts'); if (sHosts) sHosts.innerText = state.bridgeSummary?.hosts_count || 0; } if (statusRes.ok) state.systemStatus = await statusRes.json(); if (ifacesRes.ok) { const ifacesData = await ifacesRes.json(); const rawIfaces = Array.isArray(ifacesData) ? ifacesData : []; state.interfaces = rawIfaces.filter(i => { if (!i.name) return false; if (i.name.startsWith('ep_')) return false; return !['gre0', 'gretap0', 'erspan0', 'sit0', 'tunl0', 'ip6tnl0', 'ip6gre0', 'ip6erspan0'].includes(i.name); }); try { populateToolsInterfaces(); } catch (e) {} } if (vlansRes.ok) { const vlansData = await vlansRes.json(); state.vlans = Array.isArray(vlansData) ? vlansData : []; } if (vpnRes.ok) state.vpnStatus = await vpnRes.json(); if (fwRes && fwRes.ok) state.fwSummary = await fwRes.json(); if (dynRes && dynRes.ok) state.dynSummary = await dynRes.json(); updateSystemMetrics(); updateBadges(); if (state.activeTab === 'dashboard') renderDashboard(); if (state.activeTab === 'interfaces') renderInterfacesList(); if (state.activeTab === 'vlans') renderVlansList(); if (state.activeTab === 'firewall') loadFirewall(false); if (state.activeTab === 'routes') loadRoutes(false); if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') loadDynamicRouting(false); if (state.activeTab === 'ppp') loadPPP(false); if (state.activeTab === 'eoip') loadEoIP(false); if (state.activeTab === 'pppoe') loadPPPoE(false); if (state.activeTab === 'wireguard') loadWireGuard(false); if (state.activeTab === 'ipsec') loadIPSec(false); if (state.activeTab === 'l2tp') loadL2TP(false); if (state.activeTab === 'pptp') loadPPTP(false); if (state.activeTab === 'dhcp-client') loadDHCPClients(false); } catch (err) { console.error('Failed to fetch router state:', err); } finally { if (isManual && spinner) { setTimeout(() => spinner.classList.remove('animate-spin'), 300); } } } // Update Top KPI and Sidebar Telemetry function updateSystemMetrics() { const s = state.systemStatus; const v = state.vpnStatus; if (!s) return; // Sidebar Host & Uptime const sideHost = document.getElementById('side-hostname'); if (sideHost) sideHost.innerText = s.hostname || 'debian-lxc'; const sideUp = document.getElementById('side-uptime'); if (sideUp) sideUp.innerText = s.uptime_human || '0m'; const sideGw = document.getElementById('side-gateway-ip'); if (sideGw) sideGw.innerText = s.default_gateway ? `GW: ${s.default_gateway}` : 'GW: Isolated'; // Compute CPU Metrics let cpuPct = 0; if (s.cpu_pct !== undefined) { cpuPct = s.cpu_pct; } else if (s.cpu_load && s.cpu_load.length > 0) { const cores = s.cpu_cores || 1; cpuPct = Math.min(100, Math.round((parseFloat(s.cpu_load[0]) / cores) * 100)); } const cpuPctStr = `${cpuPct.toFixed(1)}%`; const cpuWidth = `${Math.max(3, Math.min(100, cpuPct))}%`; const loadStr = s.cpu_load ? s.cpu_load.join(', ') : '-'; // Sidebar CPU const sideCpuVal = document.getElementById('side-cpu-val'); if (sideCpuVal) sideCpuVal.innerText = cpuPctStr; const sideCpuBar = document.getElementById('side-cpu-bar'); if (sideCpuBar) { sideCpuBar.style.width = cpuWidth; sideCpuBar.title = `Container: ${cpuPctStr} | Host Loadavg: ${loadStr} (${s.cpu_cores || 1} cores)`; } // Dashboard CPU Card const dashCpuVal = document.getElementById('dash-cpu-val'); if (dashCpuVal) dashCpuVal.innerText = cpuPctStr; const dashCpuBar = document.getElementById('dash-cpu-bar'); if (dashCpuBar) { dashCpuBar.style.width = cpuWidth; if (cpuPct > 85) { dashCpuBar.className = 'h-full rounded-full bg-rose-500 transition-all duration-500'; } else if (cpuPct > 65) { dashCpuBar.className = 'h-full rounded-full bg-amber-500 transition-all duration-500'; } else { dashCpuBar.className = 'h-full rounded-full bg-emerald-500 transition-all duration-500'; } } const dashCpuCores = document.getElementById('dash-cpu-cores'); if (dashCpuCores) dashCpuCores.innerText = `${s.cpu_cores || 1} Cores`; const dashCpuLoad = document.getElementById('dash-cpu-load'); if (dashCpuLoad) dashCpuLoad.innerText = loadStr; const dashCpuStatus = document.getElementById('dash-cpu-status'); if (dashCpuStatus) { if (cpuPct > 85) { dashCpuStatus.innerText = 'High'; dashCpuStatus.className = 'text-[11px] font-semibold text-rose-400 uppercase px-2 py-0.5 rounded bg-rose-500/10'; } else if (cpuPct > 65) { dashCpuStatus.innerText = 'Heavy'; dashCpuStatus.className = 'text-[11px] font-semibold text-amber-400 uppercase px-2 py-0.5 rounded bg-amber-500/10'; } else { dashCpuStatus.innerText = 'Normal'; dashCpuStatus.className = 'text-[11px] font-semibold text-emerald-400 uppercase px-2 py-0.5 rounded bg-emerald-500/10'; } } // Compute RAM Metrics const memPct = Math.round(s.mem_usage_pct || 0); const memPctStr = `${memPct}%`; const memWidth = `${Math.max(3, Math.min(100, memPct))}%`; const usedMB = s.mem_used_mb || 0; const totalMB = s.mem_total_mb || 0; const freeMB = totalMB > usedMB ? totalMB - usedMB : 0; function fmtMemMB(mb) { if (mb >= 1024) { return `${(mb / 1024).toFixed(1)} GB`; } return `${mb} MB`; } // Sidebar RAM const sideRamVal = document.getElementById('side-ram-val'); if (sideRamVal) sideRamVal.innerText = memPctStr; const sideRamBar = document.getElementById('side-ram-bar'); if (sideRamBar) sideRamBar.style.width = memWidth; // Dashboard RAM Card const dashRamVal = document.getElementById('dash-ram-val'); if (dashRamVal) dashRamVal.innerText = memPctStr; const dashRamBar = document.getElementById('dash-ram-bar'); if (dashRamBar) { dashRamBar.style.width = memWidth; if (memPct > 85) { dashRamBar.className = 'h-full rounded-full bg-rose-500 transition-all duration-500'; } else if (memPct > 70) { dashRamBar.className = 'h-full rounded-full bg-amber-500 transition-all duration-500'; } else { dashRamBar.className = 'h-full rounded-full bg-indigo-500 transition-all duration-500'; } } const dashRamUsedTotal = document.getElementById('dash-ram-used-total'); if (dashRamUsedTotal) { dashRamUsedTotal.innerText = `${fmtMemMB(usedMB)} / ${fmtMemMB(totalMB)}`; } const dashRamFree = document.getElementById('dash-ram-free'); if (dashRamFree) { dashRamFree.innerText = fmtMemMB(freeMB); } const dashRamStatus = document.getElementById('dash-ram-status'); if (dashRamStatus) { if (memPct > 85) { dashRamStatus.innerText = 'High'; dashRamStatus.className = 'text-[11px] font-semibold text-rose-400 uppercase px-2 py-0.5 rounded bg-rose-500/10'; } else { dashRamStatus.innerText = 'Active'; dashRamStatus.className = 'text-[11px] font-semibold text-indigo-400 uppercase px-2 py-0.5 rounded bg-indigo-500/10'; } } // Legacy Dashboard Stats (Safe checks) const elIfacesUp = document.getElementById('stat-ifaces-up'); if (elIfacesUp) elIfacesUp.innerText = s.up_ifaces || 0; const elIfacesTot = document.getElementById('stat-ifaces-total'); if (elIfacesTot) elIfacesTot.innerText = `${s.total_ifaces || 0} Total`; const elIfacesDown = document.getElementById('stat-ifaces-down'); if (elIfacesDown) elIfacesDown.innerText = `${s.down_ifaces || 0} DOWN`; const elVlans = document.getElementById('stat-vlans-count'); if (elVlans) elVlans.innerText = s.vlan_ifaces || 0; // VPN Status Indicators if (v) { const totalActive = v.total_active_tunnels || 0; const statVpnActive = document.getElementById('stat-vpn-active'); if (statVpnActive) statVpnActive.innerText = totalActive; const tunnelsDot = document.getElementById('tunnels-dot'); const tunnelsLabel = document.getElementById('tunnels-label'); if (tunnelsDot && tunnelsLabel) { if (totalActive > 0) { tunnelsDot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse'; tunnelsLabel.innerText = `${totalActive} Active VPN Tunnel${totalActive > 1 ? 's' : ''}`; } else { tunnelsDot.className = 'w-2 h-2 rounded-full bg-slate-500'; tunnelsLabel.innerText = '0 Active VPN Tunnels'; } } setDotBadge('badge-pppoe-status', (v.active_pppoe || 0) > 0 || v.pppoe_connected); setDotBadge('badge-wg-status', (v.active_wireguard || 0) > 0 || v.wireguard_active); setDotBadge('badge-ipsec-status', (v.active_ipsec || 0) > 0 || v.ipsec_active); setDotBadge('badge-l2tp-status', (v.active_l2tp || 0) > 0 || v.l2tp_connected); setDotBadge('badge-pptp-status', (v.active_pptp || 0) > 0 || v.pptp_connected); } // Total Live Bandwidth Rate let totalRx = 0; let totalTx = 0; (state.interfaces || []).forEach(i => { if (i.rate) { totalRx += i.rate.rx_bytes_sec || 0; totalTx += i.rate.tx_bytes_sec || 0; } }); document.getElementById('stat-traffic-rx').innerText = formatTraffic(totalRx); document.getElementById('stat-traffic-tx').innerText = formatTraffic(totalTx); } function setDotBadge(id, active) { const el = document.getElementById(id); if (!el) return; if (active) { el.className = 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-400/80'; } else { el.className = 'w-2 h-2 rounded-full bg-slate-600'; } } function updateBadges() { const bIfaces = document.getElementById('badge-ifaces-count'); if (bIfaces) bIfaces.innerText = (state.interfaces || []).length; const bVlans = document.getElementById('badge-vlans-count'); if (bVlans) bVlans.innerText = (state.vlans || []).length; if (state.fwSummary) { const totalFwRules = (state.fwSummary.filter_rules_count || 0) + (state.fwSummary.nat_rules_count || 0) + (state.fwSummary.mangle_rules_count || 0); const badgeFw = document.getElementById('badge-firewall-count'); if (badgeFw) badgeFw.innerText = totalFwRules; } if (state.vpnStatus) { const v = state.vpnStatus; const bEoIP = document.getElementById('badge-eoip-count'); if (bEoIP) bEoIP.innerText = v.total_eoip || 0; const bPPPSess = document.getElementById('badge-ppp-sessions'); if (bPPPSess) bPPPSess.innerText = v.total_ppp_sessions || 0; setDotBadge('badge-pppoe-status', v.active_pppoe > 0 || v.pppoe_server_running); setDotBadge('badge-wg-status', v.active_wireguard > 0); setDotBadge('badge-ipsec-status', v.active_ipsec > 0 || v.ipsec_server_running); setDotBadge('badge-l2tp-status', v.active_l2tp > 0 || v.l2tp_server_running); setDotBadge('badge-pptp-status', v.active_pptp > 0 || v.pptp_server_running); } if (state.dynSummary) { const d = state.dynSummary; setDotBadge('badge-bgp-dot', d.bgp_running); setDotBadge('badge-ospf-dot', d.ospf_running); const bBgp = document.getElementById('badge-bgp-count'); if (bBgp) bBgp.innerText = (state.bgpPeers || []).length; const bOspf = document.getElementById('badge-ospf-count'); if (bOspf) bOspf.innerText = (state.ospfInterfaces || []).length; const bFilters = document.getElementById('badge-filters-count'); if (bFilters) bFilters.innerText = (state.routingFilters || []).length; } } // Render Dashboard View function renderDashboard() { const matrix = document.getElementById('port-matrix'); matrix.innerHTML = ''; (state.interfaces || []).forEach(iface => { const isUp = iface.state === 'UP'; const isVlan = iface.is_vlan; const portCard = document.createElement('div'); portCard.className = `p-3 rounded-xl border transition-all cursor-pointer select-none group ${ isUp ? (isVlan ? 'bg-cyan-950/20 border-cyan-800/60 hover:border-cyan-500' : 'bg-slate-800/80 border-slate-700/80 hover:border-brand-500') : 'bg-slate-900/40 border-slate-800/60 opacity-60 hover:opacity-100 hover:border-slate-700' }`; portCard.onclick = () => openIfaceModal(iface.name); let statusPillClass = isUp ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'; if (isVlan) statusPillClass = isUp ? 'bg-cyan-400 shadow-sm shadow-cyan-400/50' : 'bg-rose-500'; portCard.innerHTML = `
${iface.name}
${iface.ipv4 && iface.ipv4.length > 0 ? iface.ipv4[0] : (iface.mac || 'No IP')}
${isVlan ? `VLAN ${iface.vlan_id}` : iface.type} ${iface.rate?.rx_human || '0 B/s'}
`; matrix.appendChild(portCard); }); const ifaceTbody = document.getElementById('quick-interfaces-table'); if (ifaceTbody) { ifaceTbody.innerHTML = ''; (state.interfaces || []).forEach(i => { const isUp = i.state === 'UP'; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition cursor-pointer'; tr.onclick = () => openIfaceModal(i.name); const rxStr = i.rate?.rx_human || '0 B/s'; const txStr = i.rate?.tx_human || '0 B/s'; tr.innerHTML = ` ${i.name} ${i.role || i.type || 'Ethernet'} ${i.mac || '-'} ${i.ipv4 && i.ipv4.length > 0 ? i.ipv4.join(', ') : 'Unassigned'} ↓ ${rxStr} • ↑ ${txStr} ${i.state} `; ifaceTbody.appendChild(tr); }); } const vlanTbody = document.getElementById('quick-vlans-table'); if (vlanTbody) { vlanTbody.innerHTML = ''; } } // Expand / Collapse Handlers function toggleExpand(parentName) { if (state.expandedParents.has(parentName)) { state.expandedParents.delete(parentName); } else { state.expandedParents.add(parentName); } renderInterfacesList(); } // VLAN Support Checker: 802.1Q sub-interfaces are only supported on physical ethernet, veth, bridge, bond function supportsVLAN(iface) { if (!iface) return false; if (iface.is_vlan) return false; const name = (iface.name || '').toLowerCase(); const type = (iface.type || '').toLowerCase(); const role = (iface.role || '').toLowerCase(); // Exclude loopback if (name === 'lo' || type === 'loopback' || role === 'loopback') return false; // Exclude WireGuard (Layer 3 tunnel, no 802.1Q support) if (type === 'wireguard' || name.startsWith('wg')) return false; // Exclude PPP / PPPoE if (type === 'ppp' || name.startsWith('ppp')) return false; // Exclude other Layer 3 tunnels and dummy devices if (type === 'dummy' || type === 'gre' || type === 'sit' || type === 'tun' || type === 'tap' || name.startsWith('gre') || name.startsWith('sit') || name.startsWith('ipsec') || name.startsWith('xfrm') || name.startsWith('dummy')) { return false; } // If name already contains a dot, it's already a sub-interface if (name.includes('.')) return false; return true; } // Expand / Collapse Handlers function toggleExpand(parentName) { const childVlans = (state.vlans || []).filter(v => v.parent_name === parentName || v.name.startsWith(parentName + '.')); if (childVlans.length === 0) { // Cannot expand an interface with no VLANs state.expandedParents.delete(parentName); return; } if (state.expandedParents.has(parentName)) { state.expandedParents.delete(parentName); } else { state.expandedParents.add(parentName); } renderInterfacesList(); } function expandAllIfaces(expand) { if (expand) { (state.interfaces || []).forEach(i => { const hasVlans = (state.vlans || []).some(v => v.parent_name === i.name || v.name.startsWith(i.name + '.')); if (!i.is_vlan && hasVlans) state.expandedParents.add(i.name); }); } else { state.expandedParents.clear(); } renderInterfacesList(); } // Filter and Render Hierarchical Interfaces List function filterIfaces(filter) { state.ifaceFilter = filter || 'all'; renderInterfacesList(); } function renderInterfacesList() { const tbody = document.getElementById('interfaces-tree-tbody'); const emptyState = document.getElementById('interfaces-empty-state'); const summary = document.getElementById('ifaces-list-summary'); const query = (document.getElementById('iface-search')?.value || '').trim().toLowerCase(); tbody.innerHTML = ''; const allIfaces = state.interfaces || []; const allVlans = state.vlans || []; const parentIfaces = allIfaces.filter(i => !i.is_vlan); const filteredParents = parentIfaces.filter(parent => { const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.')); if (query) { const matchName = parent.name.toLowerCase().includes(query); const matchIp = (parent.ipv4 || []).some(ip => ip.toLowerCase().includes(query)); const matchRole = (parent.role || '').toLowerCase().includes(query); const matchType = (parent.type || '').toLowerCase().includes(query); const matchVlan = childVlans.some(v => v.name.toLowerCase().includes(query) || String(v.vlan_id).includes(query) || (v.ipv4 || []).some(ip => ip.toLowerCase().includes(query)) || (v.description || '').toLowerCase().includes(query) ); if (!matchName && !matchIp && !matchRole && !matchType && !matchVlan) return false; } if (state.ifaceFilter === 'has_vlans') return childVlans.length > 0; if (state.ifaceFilter === 'physical') return parent.type !== 'loopback'; if (state.ifaceFilter === 'up') return parent.state === 'UP'; return true; }); if (summary) { summary.innerText = `${parentIfaces.length} Interfaces • ${allVlans.length} Active VLANs`; } if (filteredParents.length === 0) { emptyState?.classList.remove('hidden'); return; } emptyState?.classList.add('hidden'); filteredParents.forEach(parent => { const isUp = parent.state === 'UP'; const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.')); const hasVlans = childVlans.length > 0; const isExpanded = hasVlans && state.expandedParents.has(parent.name); const canHaveVlan = supportsVLAN(parent); const parentRow = document.createElement('tr'); parentRow.className = `hover:bg-slate-800/50 transition border-b border-slate-800/80 ${isExpanded ? 'bg-slate-900/80' : ''}`; parentRow.innerHTML = ` ${hasVlans ? ` ` : `
`}
${parent.type === 'loopback' ? 'LO' : (parent.type === 'wireguard' ? 'WG' : (parent.type === 'bridge' ? 'BR' : 'NIC'))}
${hasVlans ? ` ${parent.name} ${childVlans.length} VLAN${childVlans.length > 1 ? 's' : ''} ` : ` ${parent.name} 0 VLANs `}
${parent.type}
${parent.role || 'LAN'} ${parent.description || '-'}
${parent.ipv4 && parent.ipv4.length > 0 ? `
${parent.ipv4[0]}
` : 'Unassigned' } ${(() => { const c = (state.dhcpClients || []).find(dc => dc.interface === parent.name); if (!c) return ''; const statusCls = c.status === 'bound' ? 'bg-amber-500/20 text-amber-300 border-amber-500/30' : (c.status === 'searching' ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-slate-800 text-slate-400 border-slate-700'); return `
⚡ DHCP: ${c.status.toUpperCase()}
`; })()}
${parent.mac || 'N/A'}
MTU ${parent.mtu}
↓ ${parent.rate?.rx_human || '0 B/s'} ↑ ${parent.rate?.tx_human || '0 B/s'}
${(() => { const c = (state.dhcpClients || []).find(dc => dc.interface === parent.name); if (c) { return ``; } return ``; })()} ${canHaveVlan ? ` ` : ''} `; tbody.appendChild(parentRow); if (isExpanded) { const drawerRow = document.createElement('tr'); drawerRow.className = 'bg-slate-950/80 border-b border-slate-800/90'; const drawerCell = document.createElement('td'); drawerCell.colSpan = 8; drawerCell.className = 'p-0'; drawerCell.innerHTML = `
VLAN Sub-interfaces on ${parent.name} ${childVlans.length} Attached
${childVlans.length === 0 ? `

No 802.1Q tagged VLANs have been created under ${parent.name} yet.

` : `
${childVlans.map(v => { const isVlanUp = v.state === 'UP'; return ` `; }).join('')}
VLAN ID Interface Device Subnet Gateway Role / Alias MTU Throughput Status Actions
VLAN ${v.vlan_id} └─ ${v.name} ${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : 'Unassigned'} ${(() => { const c = (state.dhcpClients || []).find(dc => dc.interface === v.name); if (!c) return ''; const statusCls = c.status === 'bound' ? 'bg-amber-500/20 text-amber-300 border-amber-500/30' : 'bg-slate-800 text-slate-400 border-slate-700'; return `
⚡ DHCP: ${c.status.toUpperCase()}
`; })()}
${v.role || 'VLAN'} ${v.description || '-'} ${v.mtu} ↓ ${v.rate?.rx_human || '0 B/s'} ↑ ${v.rate?.tx_human || '0 B/s'} ${(() => { const c = (state.dhcpClients || []).find(dc => dc.interface === v.name); if (c) { return ``; } return ``; })()}
`}
`; drawerRow.appendChild(drawerCell); tbody.appendChild(drawerRow); } }); } // Render VLANs Dedicated View function renderVlansList() { const tbody = document.getElementById('vlans-table-body'); if (!tbody) return; const emptyState = document.getElementById('vlans-empty-state'); const countBadge = document.getElementById('vlans-table-count'); const query = (document.getElementById('vlan-search')?.value || '').toLowerCase(); tbody.innerHTML = ''; const allVlans = state.vlans || []; if (countBadge) countBadge.innerText = allVlans.length; const filtered = allVlans.filter(v => { if (!query) return true; return v.name.toLowerCase().includes(query) || String(v.vlan_id).includes(query) || (v.description || '').toLowerCase().includes(query) || (v.ipv4 && v.ipv4.some(ip => ip.includes(query))); }); if (allVlans.length === 0) { emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); filtered.forEach(v => { const isUp = v.state === 'UP'; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs'; tr.innerHTML = ` VLAN ${v.vlan_id} ${v.name} ${v.parent_name || 'eth0'} ${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : 'Unassigned'} ${v.description || v.role || '-'} ${v.mtu} `; tbody.appendChild(tr); }); } // Toggle interface UP / DOWN async function toggleInterfaceState(name, newState) { try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: newState }) }); const data = await res.json(); if (res.ok) { showToast('Interface Updated', `Interface ${name} is now ${newState.toUpperCase()}`, 'success'); fetchData(false); } else { showToast('Error', data.message || 'Failed to toggle interface state', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ---------------- MikroTik Flag & Utilities ---------------- function renderMikroTikFlag(running, disabled) { if (running) { return `R`; } if (disabled) { return `X`; } return `-`; } function copyToClipboard(text, label = 'Text') { navigator.clipboard.writeText(text).then(() => { showToast('Copied', `${label} copied to clipboard`, 'success'); }).catch(() => { prompt('Copy value:', text); }); } // ======================== PPPoE Multi-Instance ======================== async function loadPPPoE(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients`); const list = await res.json(); state.pppoeList = Array.isArray(list) ? list : []; const badge = document.getElementById('pppoe-count-badge'); if (badge) badge.innerText = `${state.pppoeList.length} Interface${state.pppoeList.length !== 1 ? 's' : ''}`; const tbody = document.getElementById('pppoe-table-body'); const empty = document.getElementById('pppoe-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.pppoeList.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.pppoeList.forEach(inst => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(inst.running, inst.disabled)} ${inst.name} ${inst.interface} ${inst.username || '-'} ${inst.assigned_ip || '-'} ${inst.ppp_device || '-'} ${inst.default_route ? 'Yes' : 'No'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load PPPoE:', err); } } function openAddPPPoEModal() { document.getElementById('pppoe-modal-title').innerText = 'Add PPPoE Client Interface'; document.getElementById('pppoe-modal-edit-orig').value = ''; document.getElementById('pppoe-modal-name').value = `pppoe-out${(state.pppoeList || []).length + 1}`; document.getElementById('pppoe-modal-name').readOnly = false; document.getElementById('pppoe-modal-user').value = ''; document.getElementById('pppoe-modal-pass').value = ''; document.getElementById('pppoe-modal-service').value = ''; document.getElementById('pppoe-modal-mtu').value = '1492'; document.getElementById('pppoe-modal-defroute').checked = true; document.getElementById('pppoe-modal-reconnect').checked = true; document.getElementById('pppoe-modal-disabled').checked = false; // Populate interfaces const sel = document.getElementById('pppoe-modal-iface'); sel.innerHTML = ''; (state.interfaces || []).forEach(i => { if (i.name === 'lo') return; const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || i.type})`; sel.appendChild(opt); }); document.getElementById('pppoe-modal').classList.remove('hidden'); } function openEditPPPoEModal(name) { const inst = (state.pppoeList || []).find(i => i.name === name); if (!inst) return; document.getElementById('pppoe-modal-title').innerText = `Edit PPPoE Client: ${name}`; document.getElementById('pppoe-modal-edit-orig').value = name; document.getElementById('pppoe-modal-name').value = inst.name; document.getElementById('pppoe-modal-name').readOnly = true; document.getElementById('pppoe-modal-user').value = inst.username || ''; document.getElementById('pppoe-modal-pass').value = inst.password || ''; document.getElementById('pppoe-modal-service').value = inst.service_name || ''; document.getElementById('pppoe-modal-mtu').value = inst.mtu || 1492; document.getElementById('pppoe-modal-defroute').checked = inst.default_route ?? true; document.getElementById('pppoe-modal-reconnect').checked = inst.auto_reconnect ?? true; document.getElementById('pppoe-modal-disabled').checked = !!inst.disabled; const sel = document.getElementById('pppoe-modal-iface'); sel.innerHTML = ''; (state.interfaces || []).forEach(i => { if (i.name === 'lo') return; const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || i.type})`; if (i.name === inst.interface) opt.selected = true; sel.appendChild(opt); }); document.getElementById('pppoe-modal').classList.remove('hidden'); } function closePPPoEModal() { document.getElementById('pppoe-modal').classList.add('hidden'); } async function submitSavePPPoE(e) { e.preventDefault(); const name = document.getElementById('pppoe-modal-name').value.trim(); const iface = document.getElementById('pppoe-modal-iface').value; const user = document.getElementById('pppoe-modal-user').value.trim(); const pass = document.getElementById('pppoe-modal-pass').value; const service = document.getElementById('pppoe-modal-service').value.trim(); const mtu = parseInt(document.getElementById('pppoe-modal-mtu').value, 10) || 1492; const defRoute = document.getElementById('pppoe-modal-defroute').checked; const reconnect = document.getElementById('pppoe-modal-reconnect').checked; const disabled = document.getElementById('pppoe-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, interface: iface, username: user, password: pass, service_name: service, mtu, default_route: defRoute, auto_reconnect: reconnect, disabled }) }); if (res.ok) { showToast('PPPoE Saved', `Configuration for ${name} saved`, 'success'); closePPPoEModal(); loadPPPoE(false); fetchData(false); } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save PPPoE', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function connectPPPoEInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' }); if (res.ok) { showToast('Connecting', `Dialing ${name}...`, 'info'); setTimeout(() => loadPPPoE(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function disconnectPPPoEInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' }); if (res.ok) { showToast('Disconnected', `${name} session terminated`, 'info'); setTimeout(() => loadPPPoE(false), 1000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function togglePPPoEState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} is now ${enable ? 'enabled' : 'disabled'}`, 'info'); loadPPPoE(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deletePPPoEInstance(name) { if (!confirm(`Are you sure you want to delete PPPoE client ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `PPPoE client ${name} removed`, 'success'); loadPPPoE(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== WireGuard Multi-Interface & Multi-Peer ======================== state.wgSubTab = 'ifaces'; function switchWGSubTab(tab) { state.wgSubTab = tab; const ifacesPanel = document.getElementById('wg-subtab-ifaces'); const peersPanel = document.getElementById('wg-subtab-peers'); const ifacesBtn = document.getElementById('wg-subtab-btn-ifaces'); const peersBtn = document.getElementById('wg-subtab-btn-peers'); if (tab === 'ifaces') { ifacesPanel?.classList.remove('hidden'); peersPanel?.classList.add('hidden'); ifacesBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-rose-500 text-white transition flex items-center gap-2'; peersBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2'; } else { ifacesPanel?.classList.add('hidden'); peersPanel?.classList.remove('hidden'); ifacesBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2'; peersBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-rose-500 text-white transition flex items-center gap-2'; renderWGPeers(); } } async function loadWireGuard(populateInputs = true) { try { const [ifacesRes, peersRes] = await Promise.all([ fetch(`${API_BASE}/vpn/wireguard/interfaces`), fetch(`${API_BASE}/vpn/wireguard/peers`) ]); const ifaces = ifacesRes.ok ? await ifacesRes.json() : []; const peers = peersRes.ok ? await peersRes.json() : []; state.wgInterfaces = Array.isArray(ifaces) ? ifaces : []; state.wgPeers = Array.isArray(peers) ? peers : []; // Badges const ifacesCountBadge = document.getElementById('wg-ifaces-count'); if (ifacesCountBadge) ifacesCountBadge.innerText = `${state.wgInterfaces.length} Interfaces`; const peersCountBadge = document.getElementById('wg-peers-count'); if (peersCountBadge) peersCountBadge.innerText = `${state.wgPeers.length} Peers`; const subTabIfaces = document.getElementById('wg-subtab-badge-ifaces'); if (subTabIfaces) subTabIfaces.innerText = state.wgInterfaces.length; const subTabPeers = document.getElementById('wg-subtab-badge-peers'); if (subTabPeers) subTabPeers.innerText = state.wgPeers.length; // Render Interfaces const ifacesTbody = document.getElementById('wg-ifaces-table-body'); const ifacesEmpty = document.getElementById('wg-ifaces-empty'); if (ifacesTbody) { ifacesTbody.innerHTML = ''; if (state.wgInterfaces.length === 0) { if (ifacesEmpty) ifacesEmpty.classList.remove('hidden'); } else { if (ifacesEmpty) ifacesEmpty.classList.add('hidden'); state.wgInterfaces.forEach(iface => { const peersOnIface = state.wgPeers.filter(p => p.interface === iface.name).length; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(iface.running, iface.disabled)}
${escapeHtml(iface.name)} ${iface.comment ? `(${escapeHtml(iface.comment)})` : ''}
${iface.listen_port} ${iface.address || '--'} ${(iface.public_key || '').substring(0, 10)}... 📋 ${iface.mtu || 1420} ${peersOnIface} peer${peersOnIface !== 1 ? 's' : ''} ${iface.running ? ` ` : ` `} `; ifacesTbody.appendChild(tr); }); } } // Populate filter dropdown in peers tab const filterSel = document.getElementById('wg-peers-filter-iface'); if (filterSel) { const currentVal = filterSel.value; filterSel.innerHTML = ''; state.wgInterfaces.forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = i.name; if (i.name === currentVal) opt.selected = true; filterSel.appendChild(opt); }); } renderWGPeers(); } catch (err) { console.error('Failed to load WireGuard:', err); } } function renderWGPeers() { const filterSel = document.getElementById('wg-peers-filter-iface'); const selectedIface = filterSel ? filterSel.value : ''; const tbody = document.getElementById('wg-peers-table-body'); const empty = document.getElementById('wg-peers-empty'); if (!tbody) return; tbody.innerHTML = ''; const filtered = selectedIface ? state.wgPeers.filter(p => p.interface === selectedIface) : state.wgPeers; if (filtered.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); filtered.forEach(p => { const isOnline = p.latest_handshake && !p.latest_handshake.includes('Never'); const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(isOnline, p.disabled)} ${escapeHtml(p.interface)} ${escapeHtml(p.name || 'Unnamed')} ${(p.public_key || '').substring(0, 10)}... 📋 ${(p.allowed_ips || []).join(', ')} ${p.endpoint_address ? (p.endpoint_address + (p.endpoint_port ? ':' + p.endpoint_port : '')) : '--'} ${p.latest_handshake || 'Never'} ↓ ${p.transfer_rx_human || '0 B'} ↑ ${p.transfer_tx_human || '0 B'} `; tbody.appendChild(tr); }); } } function openAddWGInterfaceModal() { document.getElementById('wg-iface-modal-title').innerText = 'Add WireGuard Interface'; document.getElementById('wg-iface-modal-edit-orig').value = ''; const nameEl = document.getElementById('wg-iface-modal-name'); nameEl.value = `wg${(state.wgInterfaces || []).length}`; nameEl.readOnly = false; document.getElementById('wg-iface-modal-port').value = 51820 + (state.wgInterfaces || []).length; document.getElementById('wg-iface-modal-addr').value = `10.${10 + (state.wgInterfaces || []).length}.0.1/24`; document.getElementById('wg-iface-modal-mtu').value = '1420'; document.getElementById('wg-iface-modal-comment').value = ''; document.getElementById('wg-iface-modal-pubkey').value = ''; document.getElementById('wg-iface-modal-privkey').value = ''; document.getElementById('wg-iface-modal-disabled').checked = false; generateWGInterfaceKeys(); document.getElementById('wg-iface-modal').classList.remove('hidden'); } function openEditWGInterfaceModal(name) { const iface = (state.wgInterfaces || []).find(i => i.name === name); if (!iface) return; document.getElementById('wg-iface-modal-title').innerText = `Edit WireGuard Interface: ${name}`; document.getElementById('wg-iface-modal-edit-orig').value = name; const nameEl = document.getElementById('wg-iface-modal-name'); nameEl.value = iface.name; nameEl.readOnly = false; // ALLOW RENAMING document.getElementById('wg-iface-modal-port').value = iface.listen_port || 51820; document.getElementById('wg-iface-modal-addr').value = iface.address || ''; document.getElementById('wg-iface-modal-mtu').value = iface.mtu || 1420; document.getElementById('wg-iface-modal-comment').value = iface.comment || ''; document.getElementById('wg-iface-modal-pubkey').value = iface.public_key || ''; document.getElementById('wg-iface-modal-privkey').value = iface.private_key || ''; document.getElementById('wg-iface-modal-disabled').checked = !!iface.disabled; document.getElementById('wg-iface-modal').classList.remove('hidden'); } function closeAddWGInterfaceModal() { document.getElementById('wg-iface-modal').classList.add('hidden'); } async function generateWGInterfaceKeys() { try { const res = await fetch(`${API_BASE}/vpn/wireguard/genkeys`, { method: 'POST' }); const keys = await res.json(); if (res.ok) { document.getElementById('wg-iface-modal-privkey').value = keys.private_key; document.getElementById('wg-iface-modal-pubkey').value = keys.public_key; } } catch (err) { showToast('Error', 'Failed to generate keypair: ' + err.message, 'error'); } } async function onWGInterfacePrivKeyInput() { const privKey = document.getElementById('wg-iface-modal-privkey').value.trim(); if (!privKey) return; try { const res = await fetch(`${API_BASE}/vpn/wireguard/calc-pubkey`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ private_key: privKey }) }); if (res.ok) { const data = await res.json(); if (data.public_key) { document.getElementById('wg-iface-modal-pubkey').value = data.public_key; } } } catch (err) { // Ignore while typing } } function toggleWGPrivKeyVisibility() { const el = document.getElementById('wg-iface-modal-privkey'); const btn = document.getElementById('btn-toggle-wg-privkey'); if (el.type === 'password') { el.type = 'text'; btn.innerText = '🙈 Hide'; } else { el.type = 'password'; btn.innerText = '👁️ Show'; } } function copyWGInterfacePubKey() { const pub = document.getElementById('wg-iface-modal-pubkey').value; if (pub) copyToClipboard(pub, 'Interface Public Key'); } async function submitSaveWGInterface(e) { e.preventDefault(); const origName = document.getElementById('wg-iface-modal-edit-orig').value.trim(); const name = document.getElementById('wg-iface-modal-name').value.trim(); const port = parseInt(document.getElementById('wg-iface-modal-port').value, 10) || 51820; const addr = document.getElementById('wg-iface-modal-addr').value.trim(); const mtu = parseInt(document.getElementById('wg-iface-modal-mtu').value, 10) || 1420; const comment = document.getElementById('wg-iface-modal-comment').value.trim(); const pub = document.getElementById('wg-iface-modal-pubkey').value.trim(); const priv = document.getElementById('wg-iface-modal-privkey').value.trim(); const disabled = document.getElementById('wg-iface-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orig_name: origName, name, listen_port: port, address: addr, mtu, comment, public_key: pub, private_key: priv, disabled }) }); const data = await res.json(); if (res.ok) { showToast('WireGuard Saved', `Interface ${name} configured successfully`, 'success'); closeAddWGInterfaceModal(); loadWireGuard(false); fetchData(false); } else { showToast('Error', data.error || 'Failed to save interface', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function startWGInterface(name) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}/start`, { method: 'POST' }); if (res.ok) { showToast('Interface UP', `Started WireGuard interface ${name}`, 'success'); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function stopWGInterface(name) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}/stop`, { method: 'POST' }); if (res.ok) { showToast('Interface DOWN', `Stopped WireGuard interface ${name}`, 'info'); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleWGInterfaceState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info'); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteWGInterface(name) { if (!confirm(`Are you sure you want to delete WireGuard interface ${name}? All associated peers will also be removed.`)) return; try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `Interface ${name} removed`, 'success'); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } function populateWGPeerIfaceSelect(selectedIface = '') { const sel = document.getElementById('wg-peer-modal-iface'); if (!sel) return; sel.innerHTML = ''; (state.wgInterfaces || []).forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.address || 'No IP'})`; if (i.name === selectedIface) opt.selected = true; sel.appendChild(opt); }); } function openAddWGPeerModal() { if ((state.wgInterfaces || []).length === 0) { showToast('No Interfaces', 'Please add a WireGuard interface first before adding peers', 'warning'); openAddWGInterfaceModal(); return; } document.getElementById('wg-peer-modal-title').innerText = 'Add WireGuard Peer'; document.getElementById('wg-peer-modal-edit-orig-iface').value = ''; document.getElementById('wg-peer-modal-edit-orig-pubkey').value = ''; populateWGPeerIfaceSelect(); document.getElementById('wg-peer-modal-name').value = ''; document.getElementById('wg-peer-modal-pubkey').value = ''; document.getElementById('wg-peer-modal-allowedips').value = '10.10.0.2/32'; document.getElementById('wg-peer-modal-endpoint').value = ''; document.getElementById('wg-peer-modal-port').value = ''; document.getElementById('wg-peer-modal-keepalive').value = '25'; document.getElementById('wg-peer-modal-preshared').value = ''; document.getElementById('wg-peer-modal-disabled').checked = false; document.getElementById('wg-peer-key-warning').classList.add('hidden'); document.getElementById('wg-peer-gen-key-box').classList.add('hidden'); document.getElementById('wg-peer-modal').classList.remove('hidden'); } function openEditWGPeerModal(ifaceName, pubKey) { const peer = (state.wgPeers || []).find(p => p.interface === ifaceName && p.public_key === pubKey); if (!peer) { showToast('Not Found', 'Peer not found', 'warning'); return; } document.getElementById('wg-peer-modal-title').innerText = `Edit WireGuard Peer (${peer.name || ifaceName})`; document.getElementById('wg-peer-modal-edit-orig-iface').value = peer.interface; document.getElementById('wg-peer-modal-edit-orig-pubkey').value = peer.public_key; populateWGPeerIfaceSelect(peer.interface); document.getElementById('wg-peer-modal-name').value = peer.name || ''; document.getElementById('wg-peer-modal-pubkey').value = peer.public_key || ''; document.getElementById('wg-peer-modal-allowedips').value = (peer.allowed_ips || []).join(', '); document.getElementById('wg-peer-modal-endpoint').value = peer.endpoint_address || ''; document.getElementById('wg-peer-modal-port').value = peer.endpoint_port || ''; document.getElementById('wg-peer-modal-keepalive').value = peer.persistent_keepalive ?? 25; document.getElementById('wg-peer-modal-preshared').value = peer.preshared_key || ''; document.getElementById('wg-peer-modal-disabled').checked = !!peer.disabled; document.getElementById('wg-peer-key-warning').classList.add('hidden'); document.getElementById('wg-peer-gen-key-box').classList.add('hidden'); document.getElementById('wg-peer-modal').classList.remove('hidden'); } function closeAddWGPeerModal() { document.getElementById('wg-peer-modal').classList.add('hidden'); } async function generatePeerKeypairHelper() { try { const res = await fetch(`${API_BASE}/vpn/wireguard/genkeys`, { method: 'POST' }); const keys = await res.json(); if (res.ok) { document.getElementById('wg-peer-modal-pubkey').value = keys.public_key; document.getElementById('wg-peer-gen-privkey').value = keys.private_key; document.getElementById('wg-peer-gen-key-box').classList.remove('hidden'); checkWGPeerKeyValidity(); showToast('Keypair Generated', 'Public key inserted and client private key ready to copy', 'info'); } } catch (err) { showToast('Error', 'Failed to generate keypair: ' + err.message, 'error'); } } function copyGeneratedPeerPrivKey() { const priv = document.getElementById('wg-peer-gen-privkey').value; if (priv) copyToClipboard(priv, 'Client Private Key'); } async function generatePeerPSK() { try { const res = await fetch(`${API_BASE}/vpn/wireguard/genkeys`, { method: 'POST' }); const keys = await res.json(); if (res.ok && keys.private_key) { document.getElementById('wg-peer-modal-preshared').value = keys.private_key; showToast('PSK Generated', 'Generated 256-bit preshared key', 'info'); } } catch (err) { showToast('Error', err.message, 'error'); } } function checkWGPeerKeyValidity() { const ifaceName = document.getElementById('wg-peer-modal-iface').value; const peerPubKey = document.getElementById('wg-peer-modal-pubkey').value.trim(); const warningEl = document.getElementById('wg-peer-key-warning'); if (!warningEl) return; const targetIface = (state.wgInterfaces || []).find(i => i.name === ifaceName); if (targetIface && targetIface.public_key && peerPubKey && targetIface.public_key === peerPubKey) { warningEl.classList.remove('hidden'); } else { warningEl.classList.add('hidden'); } } async function submitSaveWGPeer(e) { e.preventDefault(); const origIface = document.getElementById('wg-peer-modal-edit-orig-iface').value.trim(); const origPubKey = document.getElementById('wg-peer-modal-edit-orig-pubkey').value.trim(); const iface = document.getElementById('wg-peer-modal-iface').value; const name = document.getElementById('wg-peer-modal-name').value.trim(); const pub = document.getElementById('wg-peer-modal-pubkey').value.trim(); const allowed = document.getElementById('wg-peer-modal-allowedips').value .split(',') .map(s => s.trim()) .filter(s => s.length > 0); const epHost = document.getElementById('wg-peer-modal-endpoint').value.trim(); const epPort = parseInt(document.getElementById('wg-peer-modal-port').value, 10) || 0; const keepalive = parseInt(document.getElementById('wg-peer-modal-keepalive').value, 10) || 0; const psk = document.getElementById('wg-peer-modal-preshared').value.trim(); const disabled = document.getElementById('wg-peer-modal-disabled').checked; // Validation const targetIface = (state.wgInterfaces || []).find(i => i.name === iface); if (targetIface && targetIface.public_key === pub) { showToast('Invalid Peer Key', 'Peer public key cannot be the same as the interface public key!', 'error'); return; } try { const res = await fetch(`${API_BASE}/vpn/wireguard/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orig_interface: origIface, orig_public_key: origPubKey, interface: iface, name, public_key: pub, allowed_ips: allowed, endpoint_address: epHost, endpoint_port: epPort, persistent_keepalive: keepalive, preshared_key: psk, disabled }) }); const data = await res.json(); if (res.ok) { showToast('Peer Saved', `Peer ${name} saved on ${iface}`, 'success'); closeAddWGPeerModal(); loadWireGuard(false); } else { showToast('Error', data.error || 'Failed to save peer', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleWGPeerState(iface, pubkey, enable) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/peers/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, public_key: pubkey, enabled: enable }) }); const data = await res.json(); if (res.ok) { showToast(enable ? 'Peer Enabled' : 'Peer Disabled', `Peer state updated on ${iface}`, 'info'); loadWireGuard(false); } else { showToast('Error', data.error || 'Failed to update peer state', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteWGPeer(iface, pubkey) { if (!confirm(`Are you sure you want to remove peer from ${iface}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/wireguard/peers/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, public_key: pubkey }) }); const data = await res.json(); if (res.ok) { showToast('Peer Deleted', 'WireGuard peer removed', 'success'); loadWireGuard(false); } else { showToast('Error', data.error || 'Failed to remove peer', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== IPSec Multi-Tunnel ======================== async function loadIPSec(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels`); const list = await res.json(); state.ipsecTunnels = Array.isArray(list) ? list : []; const activeCount = state.ipsecTunnels.filter(t => t.running || t.status === 'ESTABLISHED').length; const badge = document.getElementById('ipsec-sas-badge'); if (badge) badge.innerText = `${activeCount} Active SAs`; const tbody = document.getElementById('ipsec-table-body'); const empty = document.getElementById('ipsec-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.ipsecTunnels.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.ipsecTunnels.forEach(c => { const isEstablished = c.running || c.status === 'ESTABLISHED'; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(isEstablished, c.disabled)} ${c.name} ${c.local_subnet || '-'} ${c.remote_addrs || '-'} ${c.remote_subnet || '-'} ${c.status || (isEstablished ? 'ESTABLISHED' : 'DISCONNECTED')} ${isEstablished ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load IPSec:', err); } } function openAddIPSecModal() { document.getElementById('ipsec-modal-title').innerText = 'Configure IPSec IKEv2 Tunnel'; document.getElementById('ipsec-modal-name').value = `ipsec-${(state.ipsecTunnels || []).length + 1}`; document.getElementById('ipsec-modal-name').readOnly = false; document.getElementById('ipsec-modal-localts').value = '192.168.10.0/24'; document.getElementById('ipsec-modal-remotets').value = '192.168.20.0/24'; document.getElementById('ipsec-modal-remotegw').value = ''; document.getElementById('ipsec-modal-psk').value = ''; document.getElementById('ipsec-modal-ikeprop').value = 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-espprop').value = 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-autostart').checked = true; document.getElementById('ipsec-modal-disabled').checked = false; document.getElementById('ipsec-modal').classList.remove('hidden'); } function openEditIPSecModal(name) { const conn = (state.ipsecTunnels || []).find(c => c.name === name); if (!conn) return; document.getElementById('ipsec-modal-title').innerText = `Edit IPSec Tunnel: ${name}`; document.getElementById('ipsec-modal-name').value = conn.name; document.getElementById('ipsec-modal-name').readOnly = true; document.getElementById('ipsec-modal-localts').value = conn.local_subnet || ''; document.getElementById('ipsec-modal-remotets').value = conn.remote_subnet || ''; document.getElementById('ipsec-modal-remotegw').value = conn.remote_addrs || ''; document.getElementById('ipsec-modal-psk').value = conn.preshared_key || ''; document.getElementById('ipsec-modal-ikeprop').value = conn.ike_proposals || 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-espprop').value = conn.esp_proposals || 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-autostart').checked = conn.auto_start ?? true; document.getElementById('ipsec-modal-disabled').checked = !!conn.disabled; document.getElementById('ipsec-modal').classList.remove('hidden'); } function closeAddIPSecModal() { document.getElementById('ipsec-modal').classList.add('hidden'); } async function submitSaveIPSec(e) { e.preventDefault(); const name = document.getElementById('ipsec-modal-name').value.trim(); const localts = document.getElementById('ipsec-modal-localts').value.trim(); const remotets = document.getElementById('ipsec-modal-remotets').value.trim(); const remotegw = document.getElementById('ipsec-modal-remotegw').value.trim(); const psk = document.getElementById('ipsec-modal-psk').value; const ike = document.getElementById('ipsec-modal-ikeprop').value.trim(); const esp = document.getElementById('ipsec-modal-espprop').value.trim(); const autostart = document.getElementById('ipsec-modal-autostart').checked; const disabled = document.getElementById('ipsec-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, local_addrs: '%any', remote_addrs: remotegw, local_subnet: localts, remote_subnet: remotets, preshared_key: psk, ike_proposals: ike, esp_proposals: esp, auto_start: autostart, disabled }) }); if (res.ok) { showToast('IPSec Saved', `Tunnel ${name} configured`, 'success'); closeAddIPSecModal(); loadIPSec(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function startIPSecTunnel(name) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/start`, { method: 'POST' }); if (res.ok) { showToast('Initiating Tunnel', `Attempting SA handshake for ${name}...`, 'info'); setTimeout(() => loadIPSec(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function stopIPSecTunnel(name) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/stop`, { method: 'POST' }); if (res.ok) { showToast('Terminated', `IPSec SA for ${name} closed`, 'info'); loadIPSec(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleIPSecState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `IPSec tunnel ${name} updated`, 'info'); loadIPSec(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteIPSecConn(name) { if (!confirm(`Are you sure you want to delete IPSec tunnel ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `IPSec connection ${name} removed`, 'success'); loadIPSec(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== L2TP Multi-Instance ======================== async function loadL2TP(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients`); const list = await res.json(); state.l2tpList = Array.isArray(list) ? list : []; const badge = document.getElementById('l2tp-count-badge'); if (badge) badge.innerText = `${state.l2tpList.length} Client${state.l2tpList.length !== 1 ? 's' : ''}`; const tbody = document.getElementById('l2tp-table-body'); const empty = document.getElementById('l2tp-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.l2tpList.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.l2tpList.forEach(inst => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(inst.running, inst.disabled)} ${inst.name} ${inst.connect_to} ${inst.username || '-'} ${inst.assigned_ip || '-'} ${inst.ppp_device || '-'} ${inst.default_route ? 'Yes' : 'No'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load L2TP:', err); } } function openAddL2TPModal() { document.getElementById('l2tp-modal-title').innerText = 'Add L2TP Client Interface'; document.getElementById('l2tp-modal-name').value = `l2tp-out${(state.l2tpList || []).length + 1}`; document.getElementById('l2tp-modal-name').readOnly = false; document.getElementById('l2tp-modal-server').value = ''; document.getElementById('l2tp-modal-user').value = ''; document.getElementById('l2tp-modal-pass').value = ''; document.getElementById('l2tp-modal-ipsec').value = ''; document.getElementById('l2tp-modal-defroute').checked = true; document.getElementById('l2tp-modal-disabled').checked = false; document.getElementById('l2tp-modal').classList.remove('hidden'); } function openEditL2TPModal(name) { const inst = (state.l2tpList || []).find(i => i.name === name); if (!inst) return; document.getElementById('l2tp-modal-title').innerText = `Edit L2TP Client: ${name}`; document.getElementById('l2tp-modal-name').value = inst.name; document.getElementById('l2tp-modal-name').readOnly = true; document.getElementById('l2tp-modal-server').value = inst.connect_to || ''; document.getElementById('l2tp-modal-user').value = inst.username || ''; document.getElementById('l2tp-modal-pass').value = inst.password || ''; document.getElementById('l2tp-modal-ipsec').value = inst.ipsec_secret || ''; document.getElementById('l2tp-modal-defroute').checked = inst.default_route ?? true; document.getElementById('l2tp-modal-disabled').checked = !!inst.disabled; document.getElementById('l2tp-modal').classList.remove('hidden'); } function closeL2TPModal() { document.getElementById('l2tp-modal').classList.add('hidden'); } async function submitSaveL2TP(e) { e.preventDefault(); const name = document.getElementById('l2tp-modal-name').value.trim(); const server = document.getElementById('l2tp-modal-server').value.trim(); const user = document.getElementById('l2tp-modal-user').value.trim(); const pass = document.getElementById('l2tp-modal-pass').value; const ipsec = document.getElementById('l2tp-modal-ipsec').value; const defRoute = document.getElementById('l2tp-modal-defroute').checked; const disabled = document.getElementById('l2tp-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, connect_to: server, username: user, password: pass, ipsec_secret: ipsec, default_route: defRoute, disabled }) }); if (res.ok) { showToast('L2TP Saved', `Client ${name} configuration saved`, 'success'); closeL2TPModal(); loadL2TP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function connectL2TPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' }); if (res.ok) { showToast('Connecting', `Dialing L2TP ${name}...`, 'info'); setTimeout(() => loadL2TP(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function disconnectL2TPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' }); if (res.ok) { showToast('Disconnected', `L2TP tunnel ${name} terminated`, 'info'); loadL2TP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleL2TPState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info'); loadL2TP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteL2TPInstance(name) { if (!confirm(`Are you sure you want to delete L2TP client ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `L2TP client ${name} removed`, 'success'); loadL2TP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== PPTP Multi-Instance ======================== async function loadPPTP(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients`); const list = await res.json(); state.pptpList = Array.isArray(list) ? list : []; const badge = document.getElementById('pptp-count-badge'); if (badge) badge.innerText = `${state.pptpList.length} Client${state.pptpList.length !== 1 ? 's' : ''}`; const tbody = document.getElementById('pptp-table-body'); const empty = document.getElementById('pptp-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.pptpList.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.pptpList.forEach(inst => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(inst.running, inst.disabled)} ${inst.name} ${inst.connect_to} ${inst.username || '-'} ${inst.assigned_ip || '-'} ${inst.ppp_device || '-'} ${inst.require_mppe ? 'MPPE-128' : 'None'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load PPTP:', err); } } function openAddPPTPModal() { document.getElementById('pptp-modal-title').innerText = 'Add PPTP Client Interface'; document.getElementById('pptp-modal-name').value = `pptp-out${(state.pptpList || []).length + 1}`; document.getElementById('pptp-modal-name').readOnly = false; document.getElementById('pptp-modal-server').value = ''; document.getElementById('pptp-modal-user').value = ''; document.getElementById('pptp-modal-pass').value = ''; document.getElementById('pptp-modal-mppe').checked = true; document.getElementById('pptp-modal-defroute').checked = true; document.getElementById('pptp-modal-disabled').checked = false; document.getElementById('pptp-modal').classList.remove('hidden'); } function openEditPPTPModal(name) { const inst = (state.pptpList || []).find(i => i.name === name); if (!inst) return; document.getElementById('pptp-modal-title').innerText = `Edit PPTP Client: ${name}`; document.getElementById('pptp-modal-name').value = inst.name; document.getElementById('pptp-modal-name').readOnly = true; document.getElementById('pptp-modal-server').value = inst.connect_to || ''; document.getElementById('pptp-modal-user').value = inst.username || ''; document.getElementById('pptp-modal-pass').value = inst.password || ''; document.getElementById('pptp-modal-mppe').checked = inst.require_mppe ?? true; document.getElementById('pptp-modal-defroute').checked = inst.default_route ?? true; document.getElementById('pptp-modal-disabled').checked = !!inst.disabled; document.getElementById('pptp-modal').classList.remove('hidden'); } function closePPTPModal() { document.getElementById('pptp-modal').classList.add('hidden'); } async function submitSavePPTP(e) { e.preventDefault(); const name = document.getElementById('pptp-modal-name').value.trim(); const server = document.getElementById('pptp-modal-server').value.trim(); const user = document.getElementById('pptp-modal-user').value.trim(); const pass = document.getElementById('pptp-modal-pass').value; const mppe = document.getElementById('pptp-modal-mppe').checked; const defRoute = document.getElementById('pptp-modal-defroute').checked; const disabled = document.getElementById('pptp-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/pptp/clients`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, connect_to: server, username: user, password: pass, require_mppe: mppe, default_route: defRoute, auto_reconnect: true, disabled }) }); if (res.ok) { showToast('PPTP Saved', `Client ${name} configuration saved`, 'success'); closePPTPModal(); loadPPTP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function connectPPTPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' }); if (res.ok) { showToast('Connecting', `Dialing PPTP ${name}...`, 'info'); setTimeout(() => loadPPTP(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function disconnectPPTPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' }); if (res.ok) { showToast('Disconnected', `PPTP tunnel ${name} terminated`, 'info'); loadPPTP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function togglePPTPState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info'); loadPPTP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deletePPTPInstance(name) { if (!confirm(`Are you sure you want to delete PPTP client ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `PPTP client ${name} removed`, 'success'); loadPPTP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ---------------- Modal & Helper Functions ---------------- function openVlanModal(defaultParent = '') { const select = document.getElementById('vlan-parent'); select.innerHTML = ''; const allIfaces = state.interfaces || []; const eligible = allIfaces.filter(i => supportsVLAN(i)); if (eligible.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.innerText = 'No VLAN-capable interfaces available'; select.appendChild(opt); } else { eligible.forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || i.type || 'Ethernet'} - ${i.state})`; if (i.name === defaultParent) opt.selected = true; select.appendChild(opt); }); } document.getElementById('vlan-id').value = ''; document.getElementById('vlan-name').value = ''; document.getElementById('vlan-ip').value = ''; document.getElementById('vlan-desc').value = ''; autoSuggestVlanName(); document.getElementById('vlan-modal').classList.remove('hidden'); } function closeVlanModal() { document.getElementById('vlan-modal').classList.add('hidden'); } function autoSuggestVlanName() { const parent = document.getElementById('vlan-parent').value; if (!parent) return; const id = document.getElementById('vlan-id').value; const nameInput = document.getElementById('vlan-name'); if (parent && id) nameInput.placeholder = `${parent}.${id}`; } function setSubnetPreset(cidr) { const ipInput = document.getElementById('vlan-ip'); const id = document.getElementById('vlan-id').value || '10'; ipInput.value = `192.168.${id}.1${cidr}`; } async function submitCreateVlan(e) { e.preventDefault(); const btn = document.getElementById('vlan-submit-btn'); btn.disabled = true; btn.innerText = 'Creating...'; const parent = document.getElementById('vlan-parent').value; if (!parent) { showToast('Error', 'Please select a valid VLAN-capable parent interface', 'error'); btn.disabled = false; btn.innerText = 'Create VLAN'; return; } const id = parseInt(document.getElementById('vlan-id').value, 10); const name = document.getElementById('vlan-name').value.trim(); const ip = document.getElementById('vlan-ip').value.trim(); const role = document.getElementById('vlan-role').value; const desc = document.getElementById('vlan-desc').value.trim(); try { const res = await fetch(`${API_BASE}/vlans`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parent_name: parent, vlan_id: id, name: name, ip_address: ip, role: role, description: desc, mtu: 1500, auto_up: true }) }); const data = await res.json(); if (res.ok) { showToast('VLAN Created', `Created ${data.name} (VLAN ${data.vlan_id})`, 'success'); state.expandedParents.add(parent); closeVlanModal(); await fetchData(true); if (state.activeTab !== 'interfaces') navigate('interfaces'); } else { showToast('Creation Failed', data.message || 'Failed to create VLAN', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } finally { btn.disabled = false; btn.innerText = 'Create VLAN'; } } async function confirmDeleteVlan(name) { if (!confirm(`Delete VLAN interface ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vlans/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('VLAN Deleted', `Interface ${name} removed`, 'success'); closeIfaceModal(); fetchData(true); } } catch (err) { showToast('Error', err.message, 'error'); } } function openIfaceModal(name) { const iface = (state.interfaces || []).find(i => i.name === name); if (!iface) return; state.currentModalIface = iface.name; document.getElementById('modal-iface-name').innerText = iface.name; document.getElementById('modal-iface-badge').innerText = iface.is_vlan ? `VLAN ${iface.vlan_id}` : iface.type; document.getElementById('modal-iface-mtu').value = iface.mtu; document.getElementById('modal-iface-role').value = iface.role || 'LAN'; document.getElementById('modal-iface-desc').value = iface.description || ''; const delBtn = document.getElementById('modal-delete-vlan-btn'); if (iface.is_vlan) { delBtn.classList.remove('hidden'); } else { delBtn.classList.add('hidden'); } renderModalIPs(iface); document.getElementById('iface-modal').classList.remove('hidden'); } function renderModalIPs(iface) { const container = document.getElementById('modal-ip-list'); container.innerHTML = ''; const addrs = [...(iface.ipv4 || []), ...(iface.ipv6 || [])]; if (addrs.length === 0) { container.innerHTML = `No IP addresses currently bound`; return; } addrs.forEach(addr => { const row = document.createElement('div'); row.className = 'flex items-center justify-between p-2 rounded bg-slate-900 border border-slate-800'; row.innerHTML = ` ${addr} `; container.appendChild(row); }); } function closeIfaceModal() { document.getElementById('iface-modal').classList.add('hidden'); state.currentModalIface = null; } async function setIfaceState(name, newState) { await toggleInterfaceState(name, newState); const updated = (state.interfaces || []).find(i => i.name === name); if (updated) openIfaceModal(name); } async function applyMTU() { const mtu = parseInt(document.getElementById('modal-iface-mtu').value, 10); try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/mtu`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mtu }) }); if (res.ok) { showToast('MTU Applied', `Set MTU to ${mtu} on ${state.currentModalIface}`, 'success'); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function addInterfaceIP() { const ip = document.getElementById('modal-new-ip').value.trim(); if (!ip) return; try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/ip`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip }) }); if (res.ok) { showToast('IP Added', `Bound ${ip} to ${state.currentModalIface}`, 'success'); document.getElementById('modal-new-ip').value = ''; await fetchData(false); const iface = (state.interfaces || []).find(i => i.name === state.currentModalIface); if (iface) renderModalIPs(iface); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to add IP address', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function removeInterfaceIP(ip) { if (!confirm(`Remove IP ${ip}?`)) return; try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/ip`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip }) }); if (res.ok) { showToast('IP Removed', `Removed ${ip}`, 'success'); await fetchData(false); const iface = (state.interfaces || []).find(i => i.name === state.currentModalIface); if (iface) renderModalIPs(iface); } } catch (err) { showToast('Error', err.message, 'error'); } } async function saveInterfaceMetadata() { const role = document.getElementById('modal-iface-role').value; const desc = document.getElementById('modal-iface-desc').value.trim(); try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/metadata`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, description: desc }) }); if (res.ok) { showToast('Saved', 'Metadata updated', 'success'); closeIfaceModal(); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== MIKROTIK FIREWALL (/ip firewall) ======================== state.fwSubTab = 'filter'; state.routesSubTab = 'routes'; function renderRuleFlag(disabled, hasComment) { let flags = ''; if (disabled) flags += 'X'; if (hasComment) flags += 'C'; return flags || '-'; } function renderActionBadge(action) { const act = (action || '').toLowerCase(); let color = 'bg-slate-800 text-slate-300'; if (act === 'accept') color = 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'; else if (act === 'drop' || act === 'reject') color = 'bg-rose-500/20 text-rose-300 border border-rose-500/30'; else if (act === 'masquerade' || act === 'src-nat' || act === 'dst-nat') color = 'bg-amber-500/20 text-amber-300 border border-amber-500/30'; else if (act.startsWith('mark-')) color = 'bg-purple-500/20 text-purple-300 border border-purple-500/30'; else if (act === 'log') color = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'; return `${action}`; } function switchFWSubTab(tab) { state.fwSubTab = tab; const tabs = ['filter', 'nat', 'mangle', 'addrlist', 'geo', 'connections']; tabs.forEach(t => { const panel = document.getElementById(`fw-subtab-${t}`); const btn = document.getElementById(`fw-subtab-btn-${t}`); if (panel) { if (t === tab) panel.classList.remove('hidden'); else panel.classList.add('hidden'); } if (btn) { if (t === tab) { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-emerald-500 text-white transition flex items-center gap-2 whitespace-nowrap'; } else { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap'; } } }); const addBtn = document.getElementById('fw-primary-add-btn'); if (addBtn) { if (tab === 'filter') addBtn.innerHTML = '+ Add Filter Rule'; else if (tab === 'nat') addBtn.innerHTML = '+ Add NAT Rule'; else if (tab === 'mangle') addBtn.innerHTML = '+ Add Mangle Rule'; else if (tab === 'addrlist') addBtn.innerHTML = '+ Add Address List'; else if (tab === 'geo') addBtn.innerHTML = '⚙️ Schedule Settings'; else if (tab === 'connections') addBtn.innerHTML = 'Refresh Flows'; } if (tab === 'geo') loadGeoIP(); if (tab === 'connections') loadConnections(); } function openCurrentFWModal() { if (state.fwSubTab === 'filter') openAddFilterModal(); else if (state.fwSubTab === 'nat') openAddNATModal(); else if (state.fwSubTab === 'mangle') openAddMangleModal(); else if (state.fwSubTab === 'addrlist') openAddAddrListModal(); else if (state.fwSubTab === 'geo') openGeoIntervalModal(); else if (state.fwSubTab === 'connections') loadConnections(); } async function loadFirewall(populateDropdowns = true) { try { const [summaryRes, filterRes, natRes, mangleRes, addrlistRes, geoStatusRes, geoCountriesRes] = await Promise.all([ fetch(`${API_BASE}/firewall/summary`), fetch(`${API_BASE}/firewall/filter`), fetch(`${API_BASE}/firewall/nat`), fetch(`${API_BASE}/firewall/mangle`), fetch(`${API_BASE}/firewall/address-lists`), fetch(`${API_BASE}/firewall/geoip/status`), fetch(`${API_BASE}/firewall/geoip/countries`) ]); if (summaryRes.ok) state.fwSummary = await summaryRes.json(); if (filterRes.ok) state.filterRules = await filterRes.json(); if (natRes.ok) state.natRules = await natRes.json(); if (mangleRes.ok) state.mangleRules = await mangleRes.json(); if (addrlistRes.ok) state.addressLists = await addrlistRes.json(); if (geoStatusRes.ok) state.geoStatus = await geoStatusRes.json(); if (geoCountriesRes.ok) state.geoCountries = await geoCountriesRes.json(); // Badges const bFilter = document.getElementById('fw-badge-filter'); if (bFilter) bFilter.innerText = (state.filterRules || []).length; const bNat = document.getElementById('fw-badge-nat'); if (bNat) bNat.innerText = (state.natRules || []).length; const bMangle = document.getElementById('fw-badge-mangle'); if (bMangle) bMangle.innerText = (state.mangleRules || []).length; const bAddr = document.getElementById('fw-badge-addrlist'); if (bAddr) bAddr.innerText = (state.addressLists || []).length; const bGeo = document.getElementById('fw-badge-geo'); if (bGeo) bGeo.innerText = state.geoStatus?.enabled_count || 0; renderFilterRules(); renderNATRules(); renderMangleRules(); renderAddressLists(); renderGeoCountries(); if (state.fwSubTab === 'connections') { loadConnections(); } } catch (err) { console.error('Failed to load firewall:', err); } } // ----------------- Filter Rules ----------------- function renderFilterRules() { const chainFilter = document.getElementById('filter-filter-chain')?.value || ''; const tbody = document.getElementById('filter-table-body'); const empty = document.getElementById('filter-empty'); if (!tbody) return; tbody.innerHTML = ''; const rules = (state.filterRules || []).filter(r => !chainFilter || r.chain === chainFilter); document.getElementById('filter-count-badge').innerText = `${rules.length} Rules`; if (rules.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); rules.forEach((r, idx) => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`; const ports = [r.src_port ? 's:' + r.src_port : '', r.dst_port ? 'd:' + r.dst_port : ''].filter(Boolean).join(' '); const ifaces = [r.in_interface ? 'in:' + r.in_interface : '', r.out_interface ? 'out:' + r.out_interface : ''].filter(Boolean).join(' '); tr.innerHTML = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${r.chain} ${formatTarget(r.src_address, r.src_address_list, r.src_geoip)} ${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)} ${r.protocol || 'all'} ${ports || '-'} ${ifaces || '-'} ${renderActionBadge(r.action)} ${(r.packets || 0).toLocaleString()}p / ${formatTraffic(r.bytes || 0).replace('/s', '')} ${r.comment || '-'} `; tbody.appendChild(tr); }); } function formatTarget(addr, list, geo) { const parts = []; if (addr) parts.push(`${addr}`); if (list) { const inv = list.startsWith('!') ? '!' : ''; const clean = list.replace(/^!/, ''); parts.push(`${inv}list:${clean}`); } if (geo) { const inv = geo.startsWith('!') ? '!' : ''; const clean = geo.replace(/^!/, '').replace(/^geo:/, ''); parts.push(`🌍 ${inv}${clean}`); } if (parts.length === 0) return '0.0.0.0/0'; return parts.join(' '); } function populateInterfaceSelect(selectId, currentVal = '') { const sel = document.getElementById(selectId); if (!sel) return; sel.innerHTML = ''; (state.interfaces || []).forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || i.type})`; if (i.name === currentVal) opt.selected = true; sel.appendChild(opt); }); } function populateAddressListSelect(selectId, currentVal = '') { const sel = document.getElementById(selectId); if (!sel) return; sel.innerHTML = ''; const cleanVal = (currentVal || '').replace(/^!/, ''); const customNames = Array.from(new Set((state.addressLists || []).map(a => a.name))).sort(); customNames.forEach(name => { const count = (state.addressLists || []).filter(a => a.name === name).length; const opt = document.createElement('option'); opt.value = name; opt.innerText = `${name} (${count} entries)`; if (name === cleanVal) opt.selected = true; sel.appendChild(opt); }); if (cleanVal && !sel.querySelector(`option[value="${cleanVal}"]`)) { const opt = document.createElement('option'); opt.value = cleanVal; opt.innerText = cleanVal; opt.selected = true; sel.appendChild(opt); } } function populateGeoIPSelect(selectId, currentVal = '') { const sel = document.getElementById(selectId); if (!sel) return; sel.innerHTML = ''; const cleanVal = (currentVal || '').replace(/^!/, '').replace(/^geo:/, '').toUpperCase(); // Presets optgroup const grpPresets = document.createElement('optgroup'); grpPresets.label = '⚡ Regional Presets'; const presets = [ { code: 'EU', label: '🇪🇺 European Union (EU - 27 countries)' }, { code: 'BALKANS', label: '🏛️ Balkans (GR, CY, BG, RO, AL, etc.)' }, { code: 'BLOCKLIST', label: '🛡️ High-Risk Blocklist (CN, RU, IR, KP, BY)' } ]; presets.forEach(p => { const opt = document.createElement('option'); opt.value = p.code; opt.innerText = p.label; if (p.code === cleanVal) opt.selected = true; grpPresets.appendChild(opt); }); sel.appendChild(grpPresets); // All Countries optgroup const grpCountries = document.createElement('optgroup'); grpCountries.label = '🌍 All Countries'; const countries = (state.geoCountries || []).slice().sort((a, b) => a.name.localeCompare(b.name)); countries.forEach(c => { const opt = document.createElement('option'); opt.value = c.code; const subnetsInfo = c.subnet_count ? ` [${c.subnet_count.toLocaleString()} subnets]` : ''; opt.innerText = `${c.name} (${c.code})${subnetsInfo}`; if (c.code === cleanVal) opt.selected = true; grpCountries.appendChild(opt); }); sel.appendChild(grpCountries); } function openAddFilterModal() { document.getElementById('filter-modal-title').innerText = 'Add Firewall Filter Rule'; document.getElementById('filter-modal-id').value = ''; document.getElementById('filter-modal-chain').value = 'forward'; document.getElementById('filter-modal-action').value = 'accept'; document.getElementById('filter-modal-proto').value = 'all'; document.getElementById('filter-modal-src').value = ''; document.getElementById('filter-modal-dst').value = ''; document.getElementById('filter-modal-sport').value = ''; document.getElementById('filter-modal-dport').value = ''; document.getElementById('filter-modal-connmark').value = ''; document.getElementById('filter-modal-comment').value = ''; document.getElementById('filter-modal-disabled').checked = false; document.getElementById('filter-modal-cs-established').checked = false; document.getElementById('filter-modal-cs-related').checked = false; document.getElementById('filter-modal-cs-new').checked = false; document.getElementById('filter-modal-cs-invalid').checked = false; populateInterfaceSelect('filter-modal-iniface'); populateInterfaceSelect('filter-modal-outiface'); if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = false; if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = false; populateAddressListSelect('filter-modal-srclist'); populateAddressListSelect('filter-modal-dstlist'); if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = false; if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = false; populateGeoIPSelect('filter-modal-srcgeoip'); populateGeoIPSelect('filter-modal-dstgeoip'); toggleFilterActionFields(); document.getElementById('filter-modal').classList.remove('hidden'); } function openEditFilterModal(id) { const r = (state.filterRules || []).find(rule => rule.id === id); if (!r) return; document.getElementById('filter-modal-title').innerText = `Edit Filter Rule (#${r.order})`; document.getElementById('filter-modal-id').value = r.id; document.getElementById('filter-modal-chain').value = r.chain || 'forward'; document.getElementById('filter-modal-action').value = r.action || 'accept'; document.getElementById('filter-modal-proto').value = r.protocol || 'all'; document.getElementById('filter-modal-src').value = r.src_address || ''; document.getElementById('filter-modal-dst').value = r.dst_address || ''; document.getElementById('filter-modal-sport').value = r.src_port || ''; document.getElementById('filter-modal-dport').value = r.dst_port || ''; document.getElementById('filter-modal-connmark').value = r.connection_mark || ''; document.getElementById('filter-modal-comment').value = r.comment || ''; document.getElementById('filter-modal-disabled').checked = !!r.disabled; const states = r.connection_state || []; document.getElementById('filter-modal-cs-established').checked = states.includes('established'); document.getElementById('filter-modal-cs-related').checked = states.includes('related'); document.getElementById('filter-modal-cs-new').checked = states.includes('new'); document.getElementById('filter-modal-cs-invalid').checked = states.includes('invalid'); populateInterfaceSelect('filter-modal-iniface', r.in_interface); populateInterfaceSelect('filter-modal-outiface', r.out_interface); const srcInv = (r.src_address_list || '').startsWith('!'); const dstInv = (r.dst_address_list || '').startsWith('!'); if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = srcInv; if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = dstInv; populateAddressListSelect('filter-modal-srclist', r.src_address_list || ''); populateAddressListSelect('filter-modal-dstlist', r.dst_address_list || ''); const srcGeoInv = (r.src_geoip || '').startsWith('!'); const dstGeoInv = (r.dst_geoip || '').startsWith('!'); if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = srcGeoInv; if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = dstGeoInv; populateGeoIPSelect('filter-modal-srcgeoip', r.src_geoip || ''); populateGeoIPSelect('filter-modal-dstgeoip', r.dst_geoip || ''); toggleFilterActionFields(); document.getElementById('filter-modal').classList.remove('hidden'); } function closeFilterModal() { document.getElementById('filter-modal').classList.add('hidden'); } function toggleFilterActionFields() { const act = document.getElementById('filter-modal-action').value; const rejectBox = document.getElementById('filter-action-reject-box'); if (rejectBox) { if (act === 'reject') rejectBox.classList.remove('hidden'); else rejectBox.classList.add('hidden'); } } async function submitSaveFilterRule(e) { e.preventDefault(); const id = document.getElementById('filter-modal-id').value; const chain = document.getElementById('filter-modal-chain').value; const action = document.getElementById('filter-modal-action').value; const proto = document.getElementById('filter-modal-proto').value; const src = document.getElementById('filter-modal-src').value.trim(); const dst = document.getElementById('filter-modal-dst').value.trim(); const sport = document.getElementById('filter-modal-sport').value.trim(); const dport = document.getElementById('filter-modal-dport').value.trim(); const inIface = document.getElementById('filter-modal-iniface').value; const outIface = document.getElementById('filter-modal-outiface').value; const connmark = document.getElementById('filter-modal-connmark').value.trim(); let srclist = document.getElementById('filter-modal-srclist')?.value || ''; if (srclist && document.getElementById('filter-modal-srclist-inv')?.checked) { srclist = '!' + srclist.replace(/^!/, ''); } let dstlist = document.getElementById('filter-modal-dstlist')?.value || ''; if (dstlist && document.getElementById('filter-modal-dstlist-inv')?.checked) { dstlist = '!' + dstlist.replace(/^!/, ''); } let srcgeoip = document.getElementById('filter-modal-srcgeoip')?.value || ''; if (srcgeoip && document.getElementById('filter-modal-srcgeoip-inv')?.checked) { srcgeoip = '!' + srcgeoip.replace(/^!/, ''); } let dstgeoip = document.getElementById('filter-modal-dstgeoip')?.value || ''; if (dstgeoip && document.getElementById('filter-modal-dstgeoip-inv')?.checked) { dstgeoip = '!' + dstgeoip.replace(/^!/, ''); } const comment = document.getElementById('filter-modal-comment').value.trim(); const disabled = document.getElementById('filter-modal-disabled').checked; const rejectWith = document.getElementById('filter-modal-rejectwith')?.value || ''; const states = []; if (document.getElementById('filter-modal-cs-established').checked) states.push('established'); if (document.getElementById('filter-modal-cs-related').checked) states.push('related'); if (document.getElementById('filter-modal-cs-new').checked) states.push('new'); if (document.getElementById('filter-modal-cs-invalid').checked) states.push('invalid'); try { const res = await fetch(`${API_BASE}/firewall/filter`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, chain, action, reject_with: rejectWith, protocol: proto, src_address: src, dst_address: dst, src_port: sport, dst_port: dport, in_interface: inIface, out_interface: outIface, connection_state: states, connection_mark: connmark, src_address_list: srclist, dst_address_list: dstlist, src_geoip: srcgeoip, dst_geoip: dstgeoip, comment, disabled }) }); if (res.ok) { showToast('Filter Rule Saved', 'Rule applied successfully to firewall', 'success'); closeFilterModal(); loadFirewall(false); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function moveFilterRule(id, dir) { try { const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ direction: dir }) }); if (res.ok) loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleFilterRuleState(id, disabled) { try { const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteFilterRule(id) { if (!confirm('Are you sure you want to delete this filter rule?')) return; try { const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Filter rule removed', 'success'); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- NAT Rules ----------------- function renderNATRules() { const chainFilter = document.getElementById('nat-filter-chain')?.value || ''; const tbody = document.getElementById('nat-table-body'); const empty = document.getElementById('nat-empty'); if (!tbody) return; tbody.innerHTML = ''; const rules = (state.natRules || []).filter(r => !chainFilter || r.chain === chainFilter); document.getElementById('nat-count-badge').innerText = `${rules.length} Rules`; if (rules.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); rules.forEach((r, idx) => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`; const toTarget = [r.to_addresses, r.to_ports].filter(Boolean).join(':'); const ifaces = [r.out_interface ? 'out:' + r.out_interface : '', r.in_interface ? 'in:' + r.in_interface : ''].filter(Boolean).join(' '); tr.innerHTML = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${r.chain} ${formatTarget(r.src_address, r.src_address_list, r.src_geoip)} ${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)} ${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''} ${ifaces || '-'} ${renderActionBadge(r.action)} ${toTarget || '-'} ${(r.packets || 0).toLocaleString()}p / ${formatTraffic(r.bytes || 0).replace('/s', '')} ${r.comment || '-'} `; tbody.appendChild(tr); }); } function openAddNATModal() { document.getElementById('nat-modal-title').innerText = 'Add NAT Rule'; document.getElementById('nat-modal-id').value = ''; document.getElementById('nat-modal-chain').value = 'srcnat'; document.getElementById('nat-modal-action').value = 'masquerade'; document.getElementById('nat-modal-toaddr').value = ''; document.getElementById('nat-modal-toport').value = ''; document.getElementById('nat-modal-proto').value = 'all'; document.getElementById('nat-modal-src').value = ''; document.getElementById('nat-modal-dst').value = ''; document.getElementById('nat-modal-dport').value = ''; document.getElementById('nat-modal-comment').value = ''; document.getElementById('nat-modal-disabled').checked = false; populateInterfaceSelect('nat-modal-iniface'); populateInterfaceSelect('nat-modal-outiface'); populateAddressListSelect('nat-modal-srclist'); populateAddressListSelect('nat-modal-dstlist'); const srcInvEl = document.getElementById('nat-modal-srclist-inv'); if (srcInvEl) srcInvEl.checked = false; const dstInvEl = document.getElementById('nat-modal-dstlist-inv'); if (dstInvEl) dstInvEl.checked = false; populateGeoIPSelect('nat-modal-srcgeoip'); populateGeoIPSelect('nat-modal-dstgeoip'); const srcGeoInvEl = document.getElementById('nat-modal-srcgeoip-inv'); if (srcGeoInvEl) srcGeoInvEl.checked = false; const dstGeoInvEl = document.getElementById('nat-modal-dstgeoip-inv'); if (dstGeoInvEl) dstGeoInvEl.checked = false; toggleNATActionFields(); document.getElementById('nat-modal').classList.remove('hidden'); } function openEditNATModal(id) { const r = (state.natRules || []).find(rule => rule.id === id); if (!r) return; document.getElementById('nat-modal-title').innerText = `Edit NAT Rule (#${r.order})`; document.getElementById('nat-modal-id').value = r.id; document.getElementById('nat-modal-chain').value = r.chain || 'srcnat'; document.getElementById('nat-modal-action').value = r.action || 'masquerade'; document.getElementById('nat-modal-toaddr').value = r.to_addresses || ''; document.getElementById('nat-modal-toport').value = r.to_ports || ''; document.getElementById('nat-modal-proto').value = r.protocol || 'all'; document.getElementById('nat-modal-src').value = r.src_address || ''; document.getElementById('nat-modal-dst').value = r.dst_address || ''; document.getElementById('nat-modal-dport').value = r.dst_port || ''; document.getElementById('nat-modal-comment').value = r.comment || ''; document.getElementById('nat-modal-disabled').checked = !!r.disabled; populateInterfaceSelect('nat-modal-iniface', r.in_interface); populateInterfaceSelect('nat-modal-outiface', r.out_interface); const srcInv = (r.src_address_list || '').startsWith('!'); const dstInv = (r.dst_address_list || '').startsWith('!'); const srcInvEl = document.getElementById('nat-modal-srclist-inv'); if (srcInvEl) srcInvEl.checked = srcInv; const dstInvEl = document.getElementById('nat-modal-dstlist-inv'); if (dstInvEl) dstInvEl.checked = dstInv; populateAddressListSelect('nat-modal-srclist', r.src_address_list || ''); populateAddressListSelect('nat-modal-dstlist', r.dst_address_list || ''); const srcGeoInv = (r.src_geoip || '').startsWith('!'); const dstGeoInv = (r.dst_geoip || '').startsWith('!'); const srcGeoInvEl = document.getElementById('nat-modal-srcgeoip-inv'); if (srcGeoInvEl) srcGeoInvEl.checked = srcGeoInv; const dstGeoInvEl = document.getElementById('nat-modal-dstgeoip-inv'); if (dstGeoInvEl) dstGeoInvEl.checked = dstGeoInv; populateGeoIPSelect('nat-modal-srcgeoip', r.src_geoip || ''); populateGeoIPSelect('nat-modal-dstgeoip', r.dst_geoip || ''); toggleNATActionFields(); document.getElementById('nat-modal').classList.remove('hidden'); } function closeNATModal() { document.getElementById('nat-modal').classList.add('hidden'); } function toggleNATChainFields() { const ch = document.getElementById('nat-modal-chain').value; const actSel = document.getElementById('nat-modal-action'); if (ch === 'srcnat' && actSel.value === 'dst-nat') { actSel.value = 'masquerade'; } else if (ch === 'dstnat' && actSel.value === 'masquerade') { actSel.value = 'dst-nat'; } toggleNATActionFields(); } function toggleNATActionFields() { const act = document.getElementById('nat-modal-action').value; const toBox = document.getElementById('nat-action-to-box'); if (toBox) { if (act === 'masquerade' || act === 'accept') toBox.classList.add('hidden'); else toBox.classList.remove('hidden'); } } async function submitSaveNATRule(e) { e.preventDefault(); const id = document.getElementById('nat-modal-id').value; const chain = document.getElementById('nat-modal-chain').value; const action = document.getElementById('nat-modal-action').value; const toaddr = document.getElementById('nat-modal-toaddr').value.trim(); const toport = document.getElementById('nat-modal-toport').value.trim(); const proto = document.getElementById('nat-modal-proto').value; const src = document.getElementById('nat-modal-src').value.trim(); const dst = document.getElementById('nat-modal-dst').value.trim(); const dport = document.getElementById('nat-modal-dport').value.trim(); const inIface = document.getElementById('nat-modal-iniface').value; const outIface = document.getElementById('nat-modal-outiface').value; let srclist = document.getElementById('nat-modal-srclist')?.value || ''; if (srclist && document.getElementById('nat-modal-srclist-inv')?.checked) { srclist = '!' + srclist.replace(/^!/, ''); } let dstlist = document.getElementById('nat-modal-dstlist')?.value || ''; if (dstlist && document.getElementById('nat-modal-dstlist-inv')?.checked) { dstlist = '!' + dstlist.replace(/^!/, ''); } let srcgeoip = document.getElementById('nat-modal-srcgeoip')?.value || ''; if (srcgeoip && document.getElementById('nat-modal-srcgeoip-inv')?.checked) { srcgeoip = '!' + srcgeoip.replace(/^!/, ''); } let dstgeoip = document.getElementById('nat-modal-dstgeoip')?.value || ''; if (dstgeoip && document.getElementById('nat-modal-dstgeoip-inv')?.checked) { dstgeoip = '!' + dstgeoip.replace(/^!/, ''); } const comment = document.getElementById('nat-modal-comment').value.trim(); const disabled = document.getElementById('nat-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/firewall/nat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, chain, action, to_addresses: toaddr, to_ports: toport, protocol: proto, src_address: src, dst_address: dst, dst_port: dport, in_interface: inIface, out_interface: outIface, src_address_list: srclist, dst_address_list: dstlist, src_geoip: srcgeoip, dst_geoip: dstgeoip, comment, disabled }) }); if (res.ok) { showToast('NAT Rule Saved', 'NAT mapping updated', 'success'); closeNATModal(); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function moveNATRule(id, dir) { try { const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ direction: dir }) }); if (res.ok) loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleNATRuleState(id, disabled) { try { const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteNATRule(id) { if (!confirm('Are you sure you want to delete this NAT rule?')) return; try { const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'NAT rule removed', 'success'); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Mangle Rules ----------------- function renderMangleRules() { const chainFilter = document.getElementById('mangle-filter-chain')?.value || ''; const tbody = document.getElementById('mangle-table-body'); const empty = document.getElementById('mangle-empty'); if (!tbody) return; tbody.innerHTML = ''; const rules = (state.mangleRules || []).filter(r => !chainFilter || r.chain === chainFilter); document.getElementById('mangle-count-badge').innerText = `${rules.length} Rules`; if (rules.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); rules.forEach((r, idx) => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`; const markVal = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || (r.new_dscp ? 'DSCP:' + r.new_dscp : ''); tr.innerHTML = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${r.chain} ${formatTarget(r.src_address, r.src_address_list, r.src_geoip)} ${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)} ${renderActionBadge(r.action)} ${markVal || '-'} ${r.passthrough ? 'yes' : 'no'} ${(r.packets || 0).toLocaleString()}p / ${formatTraffic(r.bytes || 0).replace('/s', '')} ${r.comment || '-'} `; tbody.appendChild(tr); }); } function openAddMangleModal() { document.getElementById('mangle-modal-title').innerText = 'Add Mangle Rule'; document.getElementById('mangle-modal-id').value = ''; document.getElementById('mangle-modal-chain').value = 'prerouting'; document.getElementById('mangle-modal-action').value = 'mark-connection'; document.getElementById('mangle-modal-newmark').value = ''; document.getElementById('mangle-modal-passthrough').checked = true; document.getElementById('mangle-modal-src').value = ''; document.getElementById('mangle-modal-dst').value = ''; document.getElementById('mangle-modal-connmark').value = ''; document.getElementById('mangle-modal-comment').value = ''; document.getElementById('mangle-modal-disabled').checked = false; populateInterfaceSelect('mangle-modal-iniface'); populateAddressListSelect('mangle-modal-srclist'); populateAddressListSelect('mangle-modal-dstlist'); const srcInvEl = document.getElementById('mangle-modal-srclist-inv'); if (srcInvEl) srcInvEl.checked = false; const dstInvEl = document.getElementById('mangle-modal-dstlist-inv'); if (dstInvEl) dstInvEl.checked = false; populateGeoIPSelect('mangle-modal-srcgeoip'); populateGeoIPSelect('mangle-modal-dstgeoip'); const srcGeoInvEl = document.getElementById('mangle-modal-srcgeoip-inv'); if (srcGeoInvEl) srcGeoInvEl.checked = false; const dstGeoInvEl = document.getElementById('mangle-modal-dstgeoip-inv'); if (dstGeoInvEl) dstGeoInvEl.checked = false; document.getElementById('mangle-modal').classList.remove('hidden'); } function openEditMangleModal(id) { const r = (state.mangleRules || []).find(rule => rule.id === id); if (!r) return; document.getElementById('mangle-modal-title').innerText = `Edit Mangle Rule (#${r.order})`; document.getElementById('mangle-modal-id').value = r.id; document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting'; document.getElementById('mangle-modal-action').value = r.action || 'mark-connection'; document.getElementById('mangle-modal-newmark').value = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || ''; document.getElementById('mangle-modal-passthrough').checked = r.passthrough ?? true; document.getElementById('mangle-modal-src').value = r.src_address || ''; document.getElementById('mangle-modal-dst').value = r.dst_address || ''; document.getElementById('mangle-modal-connmark').value = r.connection_mark || ''; document.getElementById('mangle-modal-comment').value = r.comment || ''; document.getElementById('mangle-modal-disabled').checked = !!r.disabled; populateInterfaceSelect('mangle-modal-iniface', r.in_interface); const srcInv = (r.src_address_list || '').startsWith('!'); const dstInv = (r.dst_address_list || '').startsWith('!'); const srcInvEl = document.getElementById('mangle-modal-srclist-inv'); if (srcInvEl) srcInvEl.checked = srcInv; const dstInvEl = document.getElementById('mangle-modal-dstlist-inv'); if (dstInvEl) dstInvEl.checked = dstInv; populateAddressListSelect('mangle-modal-srclist', r.src_address_list || ''); populateAddressListSelect('mangle-modal-dstlist', r.dst_address_list || ''); const srcGeoInv = (r.src_geoip || '').startsWith('!'); const dstGeoInv = (r.dst_geoip || '').startsWith('!'); const srcGeoInvEl = document.getElementById('mangle-modal-srcgeoip-inv'); if (srcGeoInvEl) srcGeoInvEl.checked = srcGeoInv; const dstGeoInvEl = document.getElementById('mangle-modal-dstgeoip-inv'); if (dstGeoInvEl) dstGeoInvEl.checked = dstGeoInv; populateGeoIPSelect('mangle-modal-srcgeoip', r.src_geoip || ''); populateGeoIPSelect('mangle-modal-dstgeoip', r.dst_geoip || ''); document.getElementById('mangle-modal').classList.remove('hidden'); } function closeMangleModal() { document.getElementById('mangle-modal').classList.add('hidden'); } function toggleMangleActionFields() { // Handled smoothly by single dynamic input } async function submitSaveMangleRule(e) { e.preventDefault(); const id = document.getElementById('mangle-modal-id').value; const chain = document.getElementById('mangle-modal-chain').value; const action = document.getElementById('mangle-modal-action').value; const newmark = document.getElementById('mangle-modal-newmark').value.trim(); const passthrough = document.getElementById('mangle-modal-passthrough').checked; const src = document.getElementById('mangle-modal-src').value.trim(); const dst = document.getElementById('mangle-modal-dst').value.trim(); const inIface = document.getElementById('mangle-modal-iniface').value; let srclist = document.getElementById('mangle-modal-srclist')?.value || ''; if (srclist && document.getElementById('mangle-modal-srclist-inv')?.checked) { srclist = '!' + srclist.replace(/^!/, ''); } let dstlist = document.getElementById('mangle-modal-dstlist')?.value || ''; if (dstlist && document.getElementById('mangle-modal-dstlist-inv')?.checked) { dstlist = '!' + dstlist.replace(/^!/, ''); } let srcgeoip = document.getElementById('mangle-modal-srcgeoip')?.value || ''; if (srcgeoip && document.getElementById('mangle-modal-srcgeoip-inv')?.checked) { srcgeoip = '!' + srcgeoip.replace(/^!/, ''); } let dstgeoip = document.getElementById('mangle-modal-dstgeoip')?.value || ''; if (dstgeoip && document.getElementById('mangle-modal-dstgeoip-inv')?.checked) { dstgeoip = '!' + dstgeoip.replace(/^!/, ''); } const connmark = document.getElementById('mangle-modal-connmark').value.trim(); const comment = document.getElementById('mangle-modal-comment').value.trim(); const disabled = document.getElementById('mangle-modal-disabled').checked; const payload = { id, chain, action, passthrough, src_address: src, dst_address: dst, in_interface: inIface, src_address_list: srclist, dst_address_list: dstlist, src_geoip: srcgeoip, dst_geoip: dstgeoip, connection_mark: connmark, comment, disabled }; if (action === 'mark-connection') payload.new_connection_mark = newmark; else if (action === 'mark-routing') payload.new_routing_mark = newmark; else if (action === 'mark-packet') payload.new_packet_mark = newmark; else if (action === 'change-mss') payload.new_mss = newmark || 'clamp-to-pmtu'; try { const res = await fetch(`${API_BASE}/firewall/mangle`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { showToast('Mangle Rule Saved', 'Marking rule applied', 'success'); closeMangleModal(); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function moveMangleRule(id, dir) { try { const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ direction: dir }) }); if (res.ok) loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleMangleRuleState(id, disabled) { try { const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteMangleRule(id) { if (!confirm('Are you sure you want to delete this mangle rule?')) return; try { const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Mangle rule removed', 'success'); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Address Lists ----------------- function renderAddressLists() { const filterList = document.getElementById('addrlist-filter-name')?.value || ''; const tbody = document.getElementById('addrlist-table-body'); const empty = document.getElementById('addrlist-empty'); if (!tbody) return; tbody.innerHTML = ''; const entries = (state.addressLists || []).filter(e => !filterList || e.name === filterList); document.getElementById('addrlist-count-badge').innerText = `${entries.length} Entries`; // Update filter select const sel = document.getElementById('addrlist-filter-name'); if (sel) { const cur = sel.value; sel.innerHTML = ''; const set = new Set((state.addressLists || []).map(a => a.name)); set.forEach(n => { const opt = document.createElement('option'); opt.value = n; opt.innerText = n; if (n === cur) opt.selected = true; sel.appendChild(opt); }); } if (entries.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); entries.forEach((e, idx) => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${e.disabled ? 'opacity-50' : ''}`; tr.setAttribute('ondblclick', `openEditAddrListModal('${e.id}')`); tr.innerHTML = ` ${idx + 1} ${e.disabled ? 'X' : '✓'} ${e.name} ${e.address} ${e.comment || '-'} `; tbody.appendChild(tr); }); } function openAddAddrListModal() { document.getElementById('addrlist-modal-title').innerText = 'Add Address List Entry'; document.getElementById('addrlist-modal-id').value = ''; document.getElementById('addrlist-modal-name').value = ''; document.getElementById('addrlist-modal-addr').value = ''; document.getElementById('addrlist-modal-comment').value = ''; document.getElementById('addrlist-modal-disabled').checked = false; const submitBtn = document.getElementById('addrlist-modal-submit-btn'); if (submitBtn) submitBtn.innerText = 'Add to List'; document.getElementById('addrlist-modal').classList.remove('hidden'); } function openEditAddrListModal(id) { const e = (state.addressLists || []).find(item => item.id === id); if (!e) return; document.getElementById('addrlist-modal-title').innerText = `Edit Address List Entry: ${e.name}`; document.getElementById('addrlist-modal-id').value = e.id; document.getElementById('addrlist-modal-name').value = e.name; document.getElementById('addrlist-modal-addr').value = e.address; document.getElementById('addrlist-modal-comment').value = e.comment || ''; document.getElementById('addrlist-modal-disabled').checked = !!e.disabled; const submitBtn = document.getElementById('addrlist-modal-submit-btn'); if (submitBtn) submitBtn.innerText = 'Save Changes'; document.getElementById('addrlist-modal').classList.remove('hidden'); } function closeAddrListModal() { document.getElementById('addrlist-modal').classList.add('hidden'); } async function submitSaveAddrList(e) { e.preventDefault(); const id = document.getElementById('addrlist-modal-id').value; const name = document.getElementById('addrlist-modal-name').value.trim(); const addr = document.getElementById('addrlist-modal-addr').value.trim(); const comment = document.getElementById('addrlist-modal-comment').value.trim(); const disabled = document.getElementById('addrlist-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/firewall/address-lists`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, address: addr, comment, disabled }) }); if (res.ok) { showToast('Address List Item Saved', `Saved ${addr} in ${name}`, 'success'); closeAddrListModal(); loadFirewall(false); } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save address list', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleAddrListState(id, disabled) { try { const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteAddrList(id) { if (!confirm('Are you sure you want to delete this address list item?')) return; try { const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Address list item removed', 'info'); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Geo-Location IP Management ----------------- async function loadGeoIP() { try { const [statusRes, countriesRes] = await Promise.all([ fetch(`${API_BASE}/firewall/geoip/status`), fetch(`${API_BASE}/firewall/geoip/countries`) ]); if (statusRes.ok) state.geoStatus = await statusRes.json(); if (countriesRes.ok) state.geoCountries = await countriesRes.json(); updateGeoHeader(); renderGeoCountries(); } catch (err) { console.error('Failed to load GeoIP:', err); } } function getGeoUsageMap() { const usage = {}; const checkRule = (r, type) => { ['src_geoip', 'dst_geoip'].forEach(field => { let val = (r && r[field]) || ''; let code = val.replace(/^!/, '').replace(/^geo:/, '').replace(/^geo_/, '').toUpperCase().trim(); if (code) { if (!usage[code]) usage[code] = []; const label = `${type} #${r.priority !== undefined ? r.priority : (r.id || '')}`; if (!usage[code].includes(label)) usage[code].push(label); } }); }; (state.filterRules || []).forEach(r => checkRule(r, 'Filter')); (state.natRules || []).forEach(r => checkRule(r, 'NAT')); (state.mangleRules || []).forEach(r => checkRule(r, 'Mangle')); return usage; } function updateGeoHeader() { const s = state.geoStatus || {}; const statusBadge = document.getElementById('geo-status-badge'); if (statusBadge) { statusBadge.innerText = s.auto_update ? 'Auto-Update ON' : 'Auto-Update OFF'; statusBadge.className = s.auto_update ? 'px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30' : 'px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 text-slate-400'; } const intervalLabel = document.getElementById('geo-interval-label'); if (intervalLabel) { intervalLabel.innerText = `Every ${s.interval_hours || 24}h`; } const usageMap = getGeoUsageMap(); const inUseCount = Object.keys(usageMap).length; const activeCountEl = document.getElementById('geo-stat-active-countries'); if (activeCountEl) activeCountEl.innerText = inUseCount.toLocaleString(); const totalSubnetsEl = document.getElementById('geo-stat-total-subnets'); if (totalSubnetsEl) totalSubnetsEl.innerText = (s.total_subnets || 0).toLocaleString(); const lastSyncEl = document.getElementById('geo-stat-last-sync'); if (lastSyncEl) { if (s.last_updated && !s.last_updated.startsWith('0001')) { const d = new Date(s.last_updated); lastSyncEl.innerText = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else { lastSyncEl.innerText = 'Never (Sync Pending)'; } } const nextSyncEl = document.getElementById('geo-stat-next-sync'); if (nextSyncEl) { if (s.next_update && !s.next_update.startsWith('0001')) { const d = new Date(s.next_update); nextSyncEl.innerText = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else { nextSyncEl.innerText = '-'; } } const badge = document.getElementById('fw-badge-geo'); if (badge) badge.innerText = inUseCount || (state.geoCountries || []).filter(c => c.subnet_count > 0).length || 0; } function renderGeoCountries() { const tbody = document.getElementById('geo-table-body'); const empty = document.getElementById('geo-empty'); if (!tbody) return; tbody.innerHTML = ''; const q = (document.getElementById('geo-search-input')?.value || '').trim().toLowerCase(); const continent = document.getElementById('geo-continent-filter')?.value || ''; const inUseOnly = document.getElementById('geo-filter-inuse-only')?.checked; const cachedOnly = document.getElementById('geo-filter-cached-only')?.checked; const usageMap = getGeoUsageMap(); const countries = (state.geoCountries || []).filter(c => { const isUsed = !!usageMap[c.code]; if (inUseOnly && !isUsed) return false; if (cachedOnly && (!c.subnet_count || c.subnet_count === 0)) return false; if (continent && c.continent !== continent) return false; if (q) { const matchCode = c.code.toLowerCase().includes(q); const matchName = c.name.toLowerCase().includes(q); if (!matchCode && !matchName) return false; } return true; }); if (countries.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); countries.forEach(c => { const tr = document.createElement('tr'); const isUsed = !!usageMap[c.code]; const rulesUsing = usageMap[c.code] || []; tr.className = `hover:bg-slate-800/40 transition text-xs ${isUsed ? 'bg-emerald-950/20' : ''}`; let statusHtml = ''; if (isUsed) { statusHtml = `IN USE (${rulesUsing.length})`; } else if (c.subnet_count > 0) { statusHtml = 'CACHED'; } else { statusHtml = 'READY'; } tr.innerHTML = ` ${c.code} ${c.name} ${c.continent} ${c.subnet_count > 0 ? c.subnet_count.toLocaleString() + ' CIDRs' : 'Not Cached'} ${c.ipset_name} ${statusHtml} `; tbody.appendChild(tr); }); } function quickFilterGeo(preset) { const searchInput = document.getElementById('geo-search-input'); const continentSel = document.getElementById('geo-continent-filter'); const inUseCheck = document.getElementById('geo-filter-inuse-only'); const cachedCheck = document.getElementById('geo-filter-cached-only'); if (searchInput) searchInput.value = ''; if (continentSel) continentSel.value = ''; if (inUseCheck) inUseCheck.checked = false; if (cachedCheck) cachedCheck.checked = false; if (preset === 'GR') { if (searchInput) searchInput.value = 'Greece'; } else if (preset === 'Europe') { if (continentSel) continentSel.value = 'Europe'; } else if (preset === 'HIGHRISK') { if (searchInput) searchInput.value = 'China'; } renderGeoCountries(); } async function toggleGeoCountry(code, enabled) { try { const res = await fetch(`${API_BASE}/firewall/geoip/countries/${encodeURIComponent(code)}/toggle`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) }); if (res.ok) { showToast(enabled ? 'Country Enabled' : 'Country Disabled', `${code} ${enabled ? 'active in firewall' : 'removed from firewall'}`, 'success'); loadGeoIP(); } else { const err = await res.json(); showToast('Error', err.message || 'Failed to toggle country', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function updateGeoCountry(code) { try { showToast('Syncing Country', `Downloading latest IP blocks for ${code}...`, 'info'); const res = await fetch(`${API_BASE}/firewall/geoip/countries/${encodeURIComponent(code)}/update`, { method: 'POST' }); if (res.ok) { const data = await res.json(); showToast('Synced', `Loaded ${data.subnets} CIDR blocks for ${code}`, 'success'); loadGeoIP(); } else { const err = await res.json(); showToast('Sync Error', err.message || 'Failed to sync country', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function triggerGeoUpdateNow() { const spinner = document.getElementById('geo-update-spinner'); if (spinner) spinner.classList.remove('hidden'); try { const res = await fetch(`${API_BASE}/firewall/geoip/update-now`, { method: 'POST' }); if (res.ok) { showToast('Update Started', 'Updating all active GeoIP country zones in background...', 'info'); setTimeout(() => { loadGeoIP(); if (spinner) spinner.classList.add('hidden'); }, 2500); } else { const err = await res.json(); showToast('Notice', err.message || 'Update in progress', 'info'); if (spinner) spinner.classList.add('hidden'); } } catch (err) { showToast('Error', err.message, 'error'); if (spinner) spinner.classList.add('hidden'); } } async function applyGeoPreset(preset) { try { const res = await fetch(`${API_BASE}/firewall/geoip/batch-toggle`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ preset, enabled: preset !== 'NONE' }) }); if (res.ok) { showToast('Preset Applied', `Preset ${preset} updated in firewall`, 'success'); loadGeoIP(); } } catch (err) { showToast('Error', err.message, 'error'); } } function openGeoIntervalModal() { const s = state.geoStatus || {}; document.getElementById('geo-cfg-autoupdate').checked = s.auto_update !== false; const sel = document.getElementById('geo-cfg-interval'); const customBox = document.getElementById('geo-cfg-custom-box'); const customHoursInput = document.getElementById('geo-cfg-custom-hours'); const curHours = s.interval_hours || 24; const matchOption = Array.from(sel.options).find(o => parseInt(o.value, 10) === curHours); if (matchOption) { sel.value = String(curHours); customBox.classList.add('hidden'); } else { sel.value = 'custom'; customHoursInput.value = curHours; customBox.classList.remove('hidden'); } document.getElementById('geo-interval-modal').classList.remove('hidden'); } function closeGeoIntervalModal() { document.getElementById('geo-interval-modal').classList.add('hidden'); } function toggleCustomGeoInterval() { const val = document.getElementById('geo-cfg-interval').value; const customBox = document.getElementById('geo-cfg-custom-box'); if (val === 'custom') { customBox.classList.remove('hidden'); } else { customBox.classList.add('hidden'); } } async function submitGeoIntervalSettings(e) { e.preventDefault(); const autoUpdate = document.getElementById('geo-cfg-autoupdate').checked; const intervalVal = document.getElementById('geo-cfg-interval').value; let hours = 24; if (intervalVal === 'custom') { hours = parseInt(document.getElementById('geo-cfg-custom-hours').value, 10) || 24; } else { hours = parseInt(intervalVal, 10) || 24; } try { const res = await fetch(`${API_BASE}/firewall/geoip/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auto_update: autoUpdate, interval_hours: hours }) }); if (res.ok) { showToast('Schedule Saved', `Auto-update set to every ${hours} hours`, 'success'); closeGeoIntervalModal(); loadGeoIP(); } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save schedule', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function viewGeoSubnets(code, name) { if (!name) { const found = (state.geoCountries || []).find(x => x.code === code); name = found ? found.name : code; } document.getElementById('geo-subnets-title').innerText = `${name} (${code}) Subnets`; document.getElementById('geo-subnets-subtitle').innerText = `Kernel IPSet: mk_geo_${code}`; const listEl = document.getElementById('geo-subnets-list'); listEl.innerHTML = 'Loading subnets...'; document.getElementById('geo-subnets-modal').classList.remove('hidden'); try { const res = await fetch(`${API_BASE}/firewall/geoip/countries/${encodeURIComponent(code)}/subnets`); if (res.ok) { const data = await res.json(); const subnets = data.subnets || []; if (subnets.length === 0) { listEl.innerHTML = 'No cached subnets found. Click Sync to download this zone.'; } else { listEl.innerHTML = subnets.map(s => `
${s}
`).join(''); } } else { listEl.innerHTML = 'Failed to load subnets.'; } } catch (err) { listEl.innerHTML = `Error: ${err.message}`; } } function closeGeoSubnetsModal() { document.getElementById('geo-subnets-modal').classList.add('hidden'); } // ----------------- Active Connections Tracking ----------------- async function loadConnections() { try { const res = await fetch(`${API_BASE}/firewall/connections`); const data = await res.json(); state.connections = Array.isArray(data) ? data : []; document.getElementById('fw-badge-connections').innerText = state.connections.length; document.getElementById('conns-count-badge').innerText = `${state.connections.length} Flows`; renderConnections(); } catch (err) { console.error('Failed to load connections:', err); } } function renderConnections() { const search = (document.getElementById('conns-search-input')?.value || '').toLowerCase(); const protoFilter = document.getElementById('conns-filter-proto')?.value || ''; const tbody = document.getElementById('conns-table-body'); const empty = document.getElementById('conns-empty'); if (!tbody) return; tbody.innerHTML = ''; const conns = (state.connections || []).filter(c => { if (protoFilter && c.protocol !== protoFilter) return false; if (search) { const line = `${c.protocol} ${c.src_address} ${c.dst_address} ${c.src_port} ${c.dst_port} ${c.tcp_state} ${c.connection_mark}`.toLowerCase(); if (!line.includes(search)) return false; } return true; }); if (conns.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); conns.slice(0, 150).forEach(c => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; let stateColor = 'text-slate-400'; if (c.tcp_state === 'ESTABLISHED') stateColor = 'text-emerald-400 font-bold'; else if (c.tcp_state === 'TIME_WAIT' || c.tcp_state === 'SYN_SENT') stateColor = 'text-amber-400'; tr.innerHTML = ` ${c.protocol} ${c.src_address}:${c.src_port} ${c.dst_address}:${c.dst_port} ${c.reply_src_address ? c.reply_src_address + ':' + c.reply_src_port : '-'} ${c.reply_dst_address ? c.reply_dst_address + ':' + c.reply_dst_port : '-'} ${c.tcp_state} ${c.connection_mark || '-'} ${c.timeout ? c.timeout + 's' : '-'} `; tbody.appendChild(tr); }); } async function killConnection(protocol, src, sport, dst, dport) { try { const res = await fetch(`${API_BASE}/firewall/connections/kill`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ protocol, src, sport, dst, dport }) }); if (res.ok) { showToast('Terminated', `Killed flow ${src}:${sport} -> ${dst}:${dport}`, 'info'); loadConnections(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function resetFirewallCounters() { if (!confirm('Reset all firewall packet and byte counters to zero?')) return; try { const res = await fetch(`${API_BASE}/firewall/reset-counters`, { method: 'POST' }); if (res.ok) { showToast('Counters Reset', 'All rules counters reset to 0', 'success'); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== MIKROTIK ROUTING & POLICY (/ip route) ======================== function switchRoutesSubTab(tab) { state.routesSubTab = tab; const tabs = ['routes', 'rules', 'tables']; tabs.forEach(t => { const panel = document.getElementById(`routes-subtab-${t}`); const btn = document.getElementById(`routes-subtab-btn-${t}`); if (panel) { if (t === tab) panel.classList.remove('hidden'); else panel.classList.add('hidden'); } if (btn) { if (t === tab) { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-cyan-500 text-white transition flex items-center gap-2'; } else { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2'; } } }); } async function loadRoutes() { try { const [routesRes, rulesRes, tablesRes] = await Promise.all([ fetch(`${API_BASE}/routes`), fetch(`${API_BASE}/routing/rules`), fetch(`${API_BASE}/routing/tables`) ]); if (routesRes.ok) state.routes = await routesRes.json(); if (rulesRes.ok) state.policyRules = await rulesRes.json(); if (tablesRes.ok) state.routingTables = await tablesRes.json(); const bRoutes = document.getElementById('routes-badge-routes'); if (bRoutes) bRoutes.innerText = (state.routes || []).length; const bRules = document.getElementById('routes-badge-rules'); if (bRules) bRules.innerText = (state.policyRules || []).length; const bTables = document.getElementById('routes-badge-tables'); if (bTables) bTables.innerText = (state.routingTables || []).length; const bRoutesCount = document.getElementById('badge-routes-count'); if (bRoutesCount) bRoutesCount.innerText = (state.routes || []).length; renderRoutesList(); renderPolicyRules(); renderRoutingTables(); } catch (err) { console.error('Failed to load routing:', err); } } function renderRoutesList() { const tableFilter = document.getElementById('routes-filter-table')?.value || ''; const tbody = document.getElementById('routes-table-body'); const empty = document.getElementById('routes-empty'); if (!tbody) return; tbody.innerHTML = ''; // Update table filter select const sel = document.getElementById('routes-filter-table'); if (sel) { const cur = sel.value; sel.innerHTML = ''; (state.routingTables || []).forEach(t => { const opt = document.createElement('option'); opt.value = t.name; opt.innerText = t.name; if (t.name === cur) opt.selected = true; sel.appendChild(opt); }); } const routes = (state.routes || []).filter(r => !tableFilter || r.routing_table === tableFilter); document.getElementById('routes-count-badge').innerText = `${routes.length} Routes`; if (routes.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); routes.forEach(r => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${r.disabled ? 'opacity-50' : ''}`; tr.setAttribute('ondblclick', `openEditRouteModal('${r.id}')`); // MikroTik Flags string: D=Dynamic, A=Active, C=Connect, S=Static, X=Disabled let flags = ''; if (r.dynamic) flags += 'D'; if (r.active) flags += 'A'; if (r.connect) flags += 'C'; if (r.static) flags += 'S'; if (r.disabled) flags += 'X'; const isDefault = r.dst_address === 'default' || r.dst_address === '0.0.0.0/0'; tr.innerHTML = ` ${flags || '-'} ${r.dst_address} ${r.gateway || 'Direct link'} ${(r.distance !== undefined && r.distance !== null) ? r.distance : 0} ${r.routing_table || 'main'} ${r.interface || '-'} ${r.comment || '-'} ${r.static ? ` ` : ` `} `; tbody.appendChild(tr); }); } function openAddRouteModal() { document.getElementById('route-modal-title').innerText = 'Add Static Route'; document.getElementById('route-modal-id').value = ''; document.getElementById('route-modal-old-dst').value = ''; document.getElementById('route-modal-old-gw').value = ''; document.getElementById('route-modal-old-distance').value = ''; document.getElementById('route-modal-old-table').value = ''; document.getElementById('route-modal-old-iface').value = ''; const dstInput = document.getElementById('route-modal-dst'); const gwInput = document.getElementById('route-modal-gw'); const distInput = document.getElementById('route-modal-distance'); const ifaceSelect = document.getElementById('route-modal-iface'); const tableSelect = document.getElementById('route-modal-table'); const commentInput = document.getElementById('route-modal-comment'); const disCheck = document.getElementById('route-modal-disabled'); const saveBtn = document.querySelector('#route-modal button[type="submit"]'); dstInput.disabled = false; dstInput.value = '0.0.0.0/0'; gwInput.disabled = false; gwInput.value = ''; distInput.disabled = false; distInput.value = '1'; ifaceSelect.disabled = false; tableSelect.disabled = false; commentInput.disabled = false; commentInput.value = ''; disCheck.disabled = false; disCheck.checked = false; if (saveBtn) saveBtn.classList.remove('hidden'); populateInterfaceSelect('route-modal-iface'); // Populate tables tableSelect.innerHTML = ''; (state.routingTables || [{ name: 'main' }]).forEach(t => { const opt = document.createElement('option'); opt.value = t.name; opt.innerText = t.name; if (t.name === 'main') opt.selected = true; tableSelect.appendChild(opt); }); document.getElementById('route-modal').classList.remove('hidden'); } function openEditRouteModal(id) { const r = (state.routes || []).find(route => route.id === id); if (!r) return; const isStatic = !!r.static; document.getElementById('route-modal-title').innerText = isStatic ? `Edit Route: ${r.dst_address}` : `Route Properties (Read Only): ${r.dst_address}`; document.getElementById('route-modal-id').value = r.id; document.getElementById('route-modal-old-dst').value = r.dst_address || ''; document.getElementById('route-modal-old-gw').value = r.gateway || ''; document.getElementById('route-modal-old-distance').value = (r.distance !== undefined && r.distance !== null) ? r.distance : ''; document.getElementById('route-modal-old-table').value = r.routing_table || 'main'; document.getElementById('route-modal-old-iface').value = r.interface || ''; const dstInput = document.getElementById('route-modal-dst'); const gwInput = document.getElementById('route-modal-gw'); const distInput = document.getElementById('route-modal-distance'); const ifaceSelect = document.getElementById('route-modal-iface'); const tableSelect = document.getElementById('route-modal-table'); const commentInput = document.getElementById('route-modal-comment'); const disCheck = document.getElementById('route-modal-disabled'); const saveBtn = document.querySelector('#route-modal button[type="submit"]'); dstInput.value = r.dst_address; gwInput.value = r.gateway || ''; distInput.value = (r.distance !== undefined && r.distance !== null) ? r.distance : 1; commentInput.value = r.comment || ''; disCheck.checked = !!r.disabled; dstInput.disabled = !isStatic; gwInput.disabled = !isStatic; distInput.disabled = !isStatic; ifaceSelect.disabled = !isStatic; tableSelect.disabled = !isStatic; commentInput.disabled = !isStatic; disCheck.disabled = !isStatic; if (saveBtn) { if (isStatic) { saveBtn.classList.remove('hidden'); } else { saveBtn.classList.add('hidden'); } } populateInterfaceSelect('route-modal-iface', r.interface); tableSelect.innerHTML = ''; (state.routingTables || [{ name: 'main' }]).forEach(t => { const opt = document.createElement('option'); opt.value = t.name; opt.innerText = t.name; if (t.name === (r.routing_table || 'main')) opt.selected = true; tableSelect.appendChild(opt); }); document.getElementById('route-modal').classList.remove('hidden'); } function closeRouteModal() { document.getElementById('route-modal').classList.add('hidden'); } async function submitSaveRoute(e) { e.preventDefault(); const id = document.getElementById('route-modal-id').value; const oldDst = document.getElementById('route-modal-old-dst')?.value || ''; const oldGw = document.getElementById('route-modal-old-gw')?.value || ''; const oldDistStr = document.getElementById('route-modal-old-distance')?.value; const oldTable = document.getElementById('route-modal-old-table')?.value || ''; const oldIface = document.getElementById('route-modal-old-iface')?.value || ''; const dst = document.getElementById('route-modal-dst').value.trim(); const gw = document.getElementById('route-modal-gw').value.trim(); const table = document.getElementById('route-modal-table').value; const distance = parseInt(document.getElementById('route-modal-distance').value, 10) || 0; const iface = document.getElementById('route-modal-iface').value; const comment = document.getElementById('route-modal-comment').value.trim(); const disabled = document.getElementById('route-modal-disabled').checked; if (!dst) { showToast('Error', 'Destination address is required', 'error'); return; } if (!gw && !iface) { showToast('Error', 'Either Gateway IP or Interface must be specified', 'error'); return; } const payload = { id, old_dst_address: oldDst, old_gateway: oldGw, old_routing_table: oldTable, old_interface: oldIface, dst_address: dst, gateway: gw || iface, routing_table: table, distance, interface: iface, comment, disabled }; if (oldDistStr !== undefined && oldDistStr !== '') { payload.old_distance = parseInt(oldDistStr, 10); } try { const res = await fetch(`${API_BASE}/routes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { showToast('Route Saved', `Route to ${dst} installed`, 'success'); closeRouteModal(); loadRoutes(); } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save route', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleRouteState(id, disabled) { try { const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) loadRoutes(); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteRoute(id) { if (!confirm('Are you sure you want to delete this route?')) return; try { const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Route removed', 'success'); loadRoutes(); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Policy Rules (/ip route rule) ----------------- function renderPolicyRules() { const tbody = document.getElementById('rules-table-body'); const empty = document.getElementById('rules-empty'); if (!tbody) return; tbody.innerHTML = ''; const rules = state.policyRules || []; document.getElementById('rules-count-badge').innerText = `${rules.length} Rules`; if (rules.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); rules.forEach(pr => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${pr.priority} ${pr.src_address || 'all'} ${pr.dst_address || 'all'} ${pr.routing_mark || '-'} ${pr.table} ${pr.action} ${pr.priority < 32766 && pr.priority > 0 ? ` ` : `Default`} `; tbody.appendChild(tr); }); } function openAddPolicyRuleModal() { document.getElementById('prule-modal-pref').value = (state.policyRules || []).length * 10 + 1000; document.getElementById('prule-modal-mark').value = ''; document.getElementById('prule-modal-src').value = ''; document.getElementById('prule-modal-dst').value = ''; const sel = document.getElementById('prule-modal-table'); sel.innerHTML = ''; (state.routingTables || []).forEach(t => { const opt = document.createElement('option'); opt.value = t.name; opt.innerText = t.name; sel.appendChild(opt); }); document.getElementById('prule-modal').classList.remove('hidden'); } function closePolicyRuleModal() { document.getElementById('prule-modal').classList.add('hidden'); } async function submitSavePolicyRule(e) { e.preventDefault(); const pref = parseInt(document.getElementById('prule-modal-pref').value, 10) || 1000; const mark = document.getElementById('prule-modal-mark').value.trim(); const src = document.getElementById('prule-modal-src').value.trim(); const dst = document.getElementById('prule-modal-dst').value.trim(); const table = document.getElementById('prule-modal-table').value; const action = document.getElementById('prule-modal-action').value; try { const res = await fetch(`${API_BASE}/routing/rules`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ priority: pref, routing_mark: mark, src_address: src, dst_address: dst, table, action }) }); if (res.ok) { showToast('Rule Added', `Policy rule installed for table ${table}`, 'success'); closePolicyRuleModal(); loadRoutes(); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deletePolicyRule(id) { if (!confirm('Are you sure you want to remove this policy rule?')) return; try { const res = await fetch(`${API_BASE}/routing/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Rule Removed', 'Policy rule deleted', 'success'); loadRoutes(); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Routing Tables ----------------- function renderRoutingTables() { const tbody = document.getElementById('tables-table-body'); if (!tbody) return; tbody.innerHTML = ''; (state.routingTables || []).forEach(t => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono'; tr.innerHTML = ` ${t.table_id} ${t.name} ${t.comment || '-'} `; tbody.appendChild(tr); }); } function openAddTableModal() { document.getElementById('table-modal-name').value = ''; document.getElementById('table-modal-id').value = ''; document.getElementById('table-modal-comment').value = ''; document.getElementById('table-modal').classList.remove('hidden'); } function closeTableModal() { document.getElementById('table-modal').classList.add('hidden'); } async function submitSaveTable(e) { e.preventDefault(); const name = document.getElementById('table-modal-name').value.trim(); const id = parseInt(document.getElementById('table-modal-id').value, 10) || 0; const comment = document.getElementById('table-modal-comment').value.trim(); try { const res = await fetch(`${API_BASE}/routing/tables`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, table_id: id, comment }) }); if (res.ok) { showToast('Table Created', `Routing table ${name} registered`, 'success'); closeTableModal(); loadRoutes(); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function loadDebianConfig() { const view = document.getElementById('debian-config-view'); view.innerText = 'Generating live configuration...'; try { const res = await fetch(`${API_BASE}/debian/config`); const data = await res.json(); view.innerText = data.content || '# Empty config'; } catch (err) { view.innerText = 'Failed to generate Debian network config.'; } } async function saveDebianConfig() { try { const res = await fetch(`${API_BASE}/debian/apply`, { method: 'POST' }); const data = await res.json(); if (res.ok) { showToast('Configuration Saved', data.message || 'Saved to /etc/network/interfaces.d/vlans.cfg', 'success'); loadDebianConfig(); } } catch (err) { showToast('Error', err.message, 'error'); } } function showToast(title, message, type = 'info') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); const borderColors = { success: 'border-emerald-500/40 bg-slate-900/95 text-emerald-400', error: 'border-rose-500/40 bg-slate-900/95 text-rose-400', info: 'border-cyan-500/40 bg-slate-900/95 text-cyan-400' }; toast.className = `pointer-events-auto border rounded-xl p-4 shadow-2xl backdrop-blur-md flex items-start space-x-3 w-80 transform transition-all duration-300 translate-y-2 opacity-0 ${borderColors[type] || borderColors.info}`; toast.innerHTML = `
${title}
${message}
`; container.appendChild(toast); requestAnimationFrame(() => toast.classList.remove('translate-y-2', 'opacity-0')); setTimeout(() => { toast.classList.add('opacity-0', 'translate-y-2'); setTimeout(() => toast.remove(), 300); }, 4000); } function formatTraffic(bytesPerSec) { if (bytesPerSec < 1024) return `${Math.round(bytesPerSec)} B/s`; const kb = bytesPerSec / 1024; if (kb < 1024) return `${kb.toFixed(1)} KB/s`; const mb = kb / 1024; return `${mb.toFixed(2)} MB/s`; } function toggleSidebar() { const sb = document.getElementById('sidebar'); sb.classList.toggle('-translate-x-full'); } // ======================== EoIP (Ethernet over IP) ======================== async function loadEoIP(populate = true) { try { const res = await fetch(`${API_BASE}/vpn/eoip`); const data = await res.json(); state.eoipTunnels = Array.isArray(data) ? data : []; const badge = document.getElementById('eoip-count-badge'); if (badge) badge.innerText = `${state.eoipTunnels.length} Tunnels`; const sideBadge = document.getElementById('badge-eoip-count'); if (sideBadge) sideBadge.innerText = state.eoipTunnels.length; renderEoIPTunnels(); } catch (err) { console.error('Failed to load EoIP:', err); } } function renderEoIPTunnels() { const tbody = document.getElementById('eoip-table-body'); const empty = document.getElementById('eoip-empty'); if (!tbody) return; tbody.innerHTML = ''; const tunnels = state.eoipTunnels || []; if (tunnels.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); tunnels.forEach(t => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${t.disabled ? 'opacity-50' : ''}`; tr.innerHTML = ` ${renderMikroTikFlag(t.running, t.disabled)} ${t.name} ${t.tunnel_id} ${t.ip_address || 'Unassigned'} ${t.remote_address} ${t.local_address || '(any)'} ${t.mac_address || '-'} ${t.mtu || 1500} ↓ ${t.rx_human || '0 B'} ↑ ${t.tx_human || '0 B'} ${t.comment || '-'} `; tbody.appendChild(tr); }); } function openAddEoIPModal() { document.getElementById('eoip-modal-title').innerText = 'Add EoIP Tunnel'; document.getElementById('eoip-modal-id').value = ''; document.getElementById('eoip-modal-name').value = `eoip-tunnel${(state.eoipTunnels || []).length + 1}`; document.getElementById('eoip-modal-tunnelid').value = (state.eoipTunnels || []).length + 10; document.getElementById('eoip-modal-remote').value = ''; document.getElementById('eoip-modal-ip').value = ''; document.getElementById('eoip-modal-local').value = ''; document.getElementById('eoip-modal-mac').value = ''; document.getElementById('eoip-modal-mtu').value = '1500'; document.getElementById('eoip-modal-comment').value = ''; document.getElementById('eoip-modal-clampmss').checked = true; document.getElementById('eoip-modal-disabled').checked = false; document.getElementById('eoip-modal').classList.remove('hidden'); } function openEditEoIPModal(id) { const t = (state.eoipTunnels || []).find(tun => tun.id === id); if (!t) return; document.getElementById('eoip-modal-title').innerText = `Edit EoIP Tunnel (${t.name})`; document.getElementById('eoip-modal-id').value = t.id; document.getElementById('eoip-modal-name').value = t.name; document.getElementById('eoip-modal-tunnelid').value = t.tunnel_id; document.getElementById('eoip-modal-remote').value = t.remote_address; document.getElementById('eoip-modal-ip').value = t.ip_address || ''; document.getElementById('eoip-modal-local').value = t.local_address || ''; document.getElementById('eoip-modal-mac').value = t.mac_address || ''; document.getElementById('eoip-modal-mtu').value = t.mtu || 1500; document.getElementById('eoip-modal-comment').value = t.comment || ''; document.getElementById('eoip-modal-clampmss').checked = !!t.clamp_mss; document.getElementById('eoip-modal-disabled').checked = !!t.disabled; document.getElementById('eoip-modal').classList.remove('hidden'); } function closeEoIPModal() { document.getElementById('eoip-modal').classList.add('hidden'); } async function submitSaveEoIP(e) { e.preventDefault(); const id = document.getElementById('eoip-modal-id').value; const name = document.getElementById('eoip-modal-name').value.trim(); const tunnelId = parseInt(document.getElementById('eoip-modal-tunnelid').value); const remote = document.getElementById('eoip-modal-remote').value.trim(); const ipAddress = document.getElementById('eoip-modal-ip').value.trim(); const local = document.getElementById('eoip-modal-local').value.trim(); const mac = document.getElementById('eoip-modal-mac').value.trim(); const mtu = parseInt(document.getElementById('eoip-modal-mtu').value) || 1500; const comment = document.getElementById('eoip-modal-comment').value.trim(); const clampMss = document.getElementById('eoip-modal-clampmss').checked; const disabled = document.getElementById('eoip-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/eoip`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, tunnel_id: tunnelId, remote_address: remote, ip_address: ipAddress, local_address: local, mac_address: mac, mtu, clamp_mss: clampMss, comment, disabled }) }); if (res.ok) { showToast('EoIP Saved', `Tunnel ${name} configured successfully`, 'success'); closeEoIPModal(); loadEoIP(false); fetchData(false); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleEoIPState(id, disabled) { try { const res = await fetch(`${API_BASE}/vpn/eoip/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast(disabled ? 'Disabled' : 'Enabled', 'EoIP tunnel state updated', 'info'); loadEoIP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteEoIP(id) { if (!confirm('Are you sure you want to delete this EoIP tunnel?')) return; try { const res = await fetch(`${API_BASE}/vpn/eoip/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'EoIP tunnel removed', 'success'); loadEoIP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== PPP Central Hub & Servers ======================== function switchPPPSubTab(tab) { state.pppSubTab = tab; const tabs = ['secrets', 'profiles', 'pppoe', 'pptp', 'l2tp', 'active']; tabs.forEach(t => { const panel = document.getElementById(`ppp-subtab-${t}`); const btn = document.getElementById(`ppp-subtab-btn-${t}`); if (panel) { if (t === tab) panel.classList.remove('hidden'); else panel.classList.add('hidden'); } if (btn) { if (t === tab) { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-violet-500 text-white transition flex items-center gap-2 whitespace-nowrap'; } else { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap'; } } }); const addBtn = document.getElementById('ppp-primary-add-btn'); if (addBtn) { if (tab === 'secrets') addBtn.innerHTML = '+ Add User (Secret)'; else if (tab === 'profiles') addBtn.innerHTML = '+ Add Profile'; else if (tab === 'active') addBtn.innerHTML = 'Refresh Sessions'; else addBtn.innerHTML = 'Save Config'; } if (tab === 'active') loadPPPSessions(); } function openCurrentPPPModal() { if (state.pppSubTab === 'secrets') openAddPPPSecretModal(); else if (state.pppSubTab === 'profiles') openAddPPPProfileModal(); else if (state.pppSubTab === 'active') loadPPPSessions(); else if (state.pppSubTab === 'pppoe') document.querySelector('#ppp-subtab-pppoe form')?.requestSubmit(); else if (state.pppSubTab === 'pptp') document.querySelector('#ppp-subtab-pptp form')?.requestSubmit(); else if (state.pppSubTab === 'l2tp') document.querySelector('#ppp-subtab-l2tp form')?.requestSubmit(); } async function loadPPP(populateDropdowns = true) { await Promise.all([ loadPPPSecrets(), loadPPPProfiles(), loadPPPServers(), loadPPPSessions() ]); } async function loadPPPSecrets() { try { const res = await fetch(`${API_BASE}/vpn/ppp/secrets`); const data = await res.json(); state.pppSecrets = Array.isArray(data) ? data : []; const b = document.getElementById('ppp-badge-secrets'); if (b) b.innerText = state.pppSecrets.length; const b2 = document.getElementById('secrets-count-badge'); if (b2) b2.innerText = `${state.pppSecrets.length} Users`; renderPPPSecrets(); } catch (err) { console.error('Failed to load PPP secrets:', err); } } function renderPPPSecrets() { const tbody = document.getElementById('secrets-table-body'); const empty = document.getElementById('secrets-empty'); if (!tbody) return; tbody.innerHTML = ''; const secrets = state.pppSecrets || []; if (secrets.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); secrets.forEach(s => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${s.disabled ? 'opacity-50' : ''}`; tr.innerHTML = ` ${renderRuleFlag(s.disabled, !!s.comment)} ${s.name} ${s.service} ${s.profile} ${s.local_address || '(profile)'} ${s.remote_address || '(profile)'} ${s.comment || '-'} `; tbody.appendChild(tr); }); } async function loadPPPProfiles() { try { const res = await fetch(`${API_BASE}/vpn/ppp/profiles`); const data = await res.json(); state.pppProfiles = Array.isArray(data) ? data : []; const b = document.getElementById('ppp-badge-profiles'); if (b) b.innerText = state.pppProfiles.length; const b2 = document.getElementById('profiles-count-badge'); if (b2) b2.innerText = `${state.pppProfiles.length} Profiles`; // Populate profile selects across modals const selects = ['secret-modal-profile', 'pppoe-server-profile', 'pptp-server-profile', 'l2tp-server-profile']; selects.forEach(sid => { const el = document.getElementById(sid); if (!el) return; const cur = el.value; el.innerHTML = ''; state.pppProfiles.forEach(p => { const opt = document.createElement('option'); opt.value = p.name; opt.innerText = `${p.name} (${p.remote_address})`; if (p.name === cur) opt.selected = true; el.appendChild(opt); }); }); renderPPPProfiles(); } catch (err) { console.error('Failed to load PPP profiles:', err); } } function renderPPPProfiles() { const tbody = document.getElementById('profiles-table-body'); if (!tbody) return; tbody.innerHTML = ''; (state.pppProfiles || []).forEach(p => { const isDefault = p.name.startsWith('default'); const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono'; tr.innerHTML = ` ${p.name} ${p.local_address} ${p.remote_address} ${p.dns_servers || '-'} ${p.use_encryption} ${p.only_one ? 'yes' : 'no'} ${p.comment || '-'} ${!isDefault ? `` : `Built-in`} `; tbody.appendChild(tr); }); } async function loadPPPServers() { try { const [pppoeRes, pptpRes, l2tpRes, ipsecRes] = await Promise.all([ fetch(`${API_BASE}/vpn/servers/pppoe`), fetch(`${API_BASE}/vpn/servers/pptp`), fetch(`${API_BASE}/vpn/servers/l2tp`), fetch(`${API_BASE}/vpn/servers/ipsec`) ]); if (pppoeRes.ok) { state.pppoeServer = await pppoeRes.json(); const s = state.pppoeServer; document.getElementById('pppoe-server-enabled').checked = !!s.enabled; document.getElementById('pppoe-server-name').value = s.service_name || 'proxmox-pppoe'; populateInterfaceSelect('pppoe-server-iface', s.interface || 'eth0'); document.getElementById('pppoe-server-profile').value = s.default_profile || 'default'; document.getElementById('pppoe-server-mtu').value = s.max_mtu || 1480; document.getElementById('pppoe-server-onesession').checked = !!s.one_session_per_host; const pill = document.getElementById('pppoe-server-status-pill'); if (pill) { pill.innerText = s.running ? `ACTIVE (${s.active_sessions} sess)` : 'STOPPED'; pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-blue-500/20 text-blue-300 border border-blue-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400'; } setDotBadge('ppp-badge-pppoe-state', s.running); } if (pptpRes.ok) { state.pptpServer = await pptpRes.json(); const s = state.pptpServer; document.getElementById('pptp-server-enabled').checked = !!s.enabled; document.getElementById('pptp-server-port').value = s.port || 1723; document.getElementById('pptp-server-profile').value = s.default_profile || 'default-encryption'; document.getElementById('pptp-server-mppe').checked = s.require_mppe !== false; const pill = document.getElementById('pptp-server-status-pill'); if (pill) { pill.innerText = s.running ? `ACTIVE (${s.active_sessions} sess)` : 'STOPPED'; pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400'; } setDotBadge('ppp-badge-pptp-state', s.running); } if (l2tpRes.ok) { state.l2tpServer = await l2tpRes.json(); const s = state.l2tpServer; document.getElementById('l2tp-server-enabled').checked = !!s.enabled; document.getElementById('l2tp-server-port').value = s.port || 1701; document.getElementById('l2tp-server-profile').value = s.default_profile || 'default'; document.getElementById('l2tp-server-useipsec').value = s.use_ipsec || 'no'; document.getElementById('l2tp-server-ipsecsecret').value = s.ipsec_secret || ''; const pill = document.getElementById('l2tp-server-status-pill'); if (pill) { pill.innerText = s.running ? `ACTIVE (${s.active_sessions} sess)` : 'STOPPED'; pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400'; } setDotBadge('ppp-badge-l2tp-state', s.running); } if (ipsecRes.ok) { state.ipsecServer = await ipsecRes.json(); const s = state.ipsecServer; const modalEn = document.getElementById('modal-ipsec-server-enabled'); if (modalEn) modalEn.checked = !!s.enabled; const modalPsk = document.getElementById('modal-ipsec-server-psk'); if (modalPsk) modalPsk.value = s.preshared_key || ''; const modalPool = document.getElementById('modal-ipsec-server-pool'); if (modalPool) modalPool.value = s.pool_address || '10.10.30.0/24'; const modalDns = document.getElementById('modal-ipsec-server-dns'); if (modalDns) modalDns.value = s.dns_servers || '1.1.1.1,8.8.8.8'; const modalSubnet = document.getElementById('modal-ipsec-server-subnet'); if (modalSubnet) modalSubnet.value = s.local_subnet || '0.0.0.0/0'; const pill = document.getElementById('modal-ipsec-server-status'); if (pill) { pill.innerText = s.running ? 'ACTIVE RESPONDER' : 'STOPPED'; pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400'; } } } catch (err) { console.error('Failed to load VPN servers:', err); } } async function loadPPPSessions() { try { const res = await fetch(`${API_BASE}/vpn/ppp/active`); const data = await res.json(); state.pppActiveSessions = Array.isArray(data) ? data : []; const b = document.getElementById('ppp-badge-active'); if (b) b.innerText = state.pppActiveSessions.length; const sideB = document.getElementById('badge-ppp-sessions'); if (sideB) sideB.innerText = state.pppActiveSessions.length; renderPPPSessions(); } catch (err) { console.error('Failed to load active PPP sessions:', err); } } function renderPPPSessions() { const tbody = document.getElementById('active-ppp-table-body'); const empty = document.getElementById('active-ppp-empty'); if (!tbody) return; tbody.innerHTML = ''; const list = state.pppActiveSessions || []; if (list.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); list.forEach(s => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono'; tr.innerHTML = ` ${s.interface} ${s.username} ${s.service} ${s.address || '-'} ${s.uptime} ↓ ${s.rx_human || '0 B'} ↑ ${s.tx_human || '0 B'} `; tbody.appendChild(tr); }); } async function disconnectPPPSession(iface) { if (!confirm(`Disconnect session ${iface}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/ppp/active/${encodeURIComponent(iface)}`, { method: 'DELETE' }); if (res.ok) { showToast('Disconnected', `Session ${iface} terminated`, 'info'); loadPPPSessions(); } } catch (err) { showToast('Error', err.message, 'error'); } } // PPP Secret Modal handlers function openAddPPPSecretModal() { document.getElementById('secret-modal-title').innerText = 'Add PPP Secret'; document.getElementById('secret-modal-id').value = ''; document.getElementById('secret-modal-name').value = ''; document.getElementById('secret-modal-password').value = ''; document.getElementById('secret-modal-service').value = 'any'; document.getElementById('secret-modal-profile').value = 'default'; document.getElementById('secret-modal-local').value = ''; document.getElementById('secret-modal-remote').value = ''; document.getElementById('secret-modal-comment').value = ''; document.getElementById('secret-modal-disabled').checked = false; document.getElementById('ppp-secret-modal').classList.remove('hidden'); } function openEditPPPSecretModal(id) { const s = (state.pppSecrets || []).find(sec => sec.id === id); if (!s) return; document.getElementById('secret-modal-title').innerText = `Edit PPP Secret (${s.name})`; document.getElementById('secret-modal-id').value = s.id; document.getElementById('secret-modal-name').value = s.name; document.getElementById('secret-modal-password').value = s.password; document.getElementById('secret-modal-service').value = s.service || 'any'; document.getElementById('secret-modal-profile').value = s.profile || 'default'; document.getElementById('secret-modal-local').value = s.local_address || ''; document.getElementById('secret-modal-remote').value = s.remote_address || ''; document.getElementById('secret-modal-comment').value = s.comment || ''; document.getElementById('secret-modal-disabled').checked = !!s.disabled; document.getElementById('ppp-secret-modal').classList.remove('hidden'); } function closePPPSecretModal() { document.getElementById('ppp-secret-modal').classList.add('hidden'); } async function submitSavePPPSecret(e) { e.preventDefault(); const id = document.getElementById('secret-modal-id').value; const name = document.getElementById('secret-modal-name').value.trim(); const password = document.getElementById('secret-modal-password').value.trim(); const service = document.getElementById('secret-modal-service').value; const profile = document.getElementById('secret-modal-profile').value; const local = document.getElementById('secret-modal-local').value.trim(); const remote = document.getElementById('secret-modal-remote').value.trim(); const comment = document.getElementById('secret-modal-comment').value.trim(); const disabled = document.getElementById('secret-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/ppp/secrets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, password, service, profile, local_address: local, remote_address: remote, comment, disabled }) }); if (res.ok) { showToast('Secret Saved', `User ${name} saved to PPP database`, 'success'); closePPPSecretModal(); loadPPPSecrets(); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function togglePPPSecretState(id, disabled) { try { const res = await fetch(`${API_BASE}/vpn/ppp/secrets/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast(disabled ? 'Disabled' : 'Enabled', 'Secret user state updated', 'info'); loadPPPSecrets(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deletePPPSecret(id) { if (!confirm('Are you sure you want to delete this secret account?')) return; try { const res = await fetch(`${API_BASE}/vpn/ppp/secrets/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Secret account removed', 'success'); loadPPPSecrets(); } } catch (err) { showToast('Error', err.message, 'error'); } } // PPP Profile Modal handlers function openAddPPPProfileModal() { document.getElementById('profile-modal-name').value = ''; document.getElementById('profile-modal-local').value = '10.10.10.1'; document.getElementById('profile-modal-remote').value = '10.10.10.10-10.10.10.50'; document.getElementById('profile-modal-dns').value = '1.1.1.1,8.8.8.8'; document.getElementById('profile-modal-encryption').value = 'required'; document.getElementById('profile-modal-onlyone').checked = true; document.getElementById('profile-modal-comment').value = ''; document.getElementById('ppp-profile-modal').classList.remove('hidden'); } function closePPPProfileModal() { document.getElementById('ppp-profile-modal').classList.add('hidden'); } async function submitSavePPPProfile(e) { e.preventDefault(); const name = document.getElementById('profile-modal-name').value.trim(); const local = document.getElementById('profile-modal-local').value.trim(); const remote = document.getElementById('profile-modal-remote').value.trim(); const dns = document.getElementById('profile-modal-dns').value.trim(); const enc = document.getElementById('profile-modal-encryption').value; const onlyone = document.getElementById('profile-modal-onlyone').checked; const comment = document.getElementById('profile-modal-comment').value.trim(); try { const res = await fetch(`${API_BASE}/vpn/ppp/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: name, name, local_address: local, remote_address: remote, dns_servers: dns, use_encryption: enc, only_one: onlyone, comment }) }); if (res.ok) { showToast('Profile Saved', `PPP profile ${name} created`, 'success'); closePPPProfileModal(); loadPPPProfiles(); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deletePPPProfile(name) { if (!confirm(`Delete PPP profile ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/ppp/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Profile removed', 'success'); loadPPPProfiles(); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // Server Config submissions async function savePPPoEServerConfig(e) { e.preventDefault(); const enabled = document.getElementById('pppoe-server-enabled').checked; const name = document.getElementById('pppoe-server-name').value.trim(); const iface = document.getElementById('pppoe-server-iface').value; const profile = document.getElementById('pppoe-server-profile').value; const mtu = parseInt(document.getElementById('pppoe-server-mtu').value) || 1480; const onesession = document.getElementById('pppoe-server-onesession').checked; try { const res = await fetch(`${API_BASE}/vpn/servers/pppoe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, service_name: name, interface: iface, default_profile: profile, max_mtu: mtu, max_mru: mtu, one_session_per_host: onesession }) }); if (res.ok) { showToast('PPPoE Server', enabled ? 'PPPoE Server started' : 'PPPoE Server stopped', 'success'); loadPPPServers(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function savePPTPServerConfig(e) { e.preventDefault(); const enabled = document.getElementById('pptp-server-enabled').checked; const port = parseInt(document.getElementById('pptp-server-port').value) || 1723; const profile = document.getElementById('pptp-server-profile').value; const mppe = document.getElementById('pptp-server-mppe').checked; try { const res = await fetch(`${API_BASE}/vpn/servers/pptp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, port, default_profile: profile, require_mppe: mppe }) }); if (res.ok) { showToast('PPTP Server', enabled ? 'PPTP Server started' : 'PPTP Server stopped', 'success'); loadPPPServers(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function saveL2TPServerConfig(e) { e.preventDefault(); const enabled = document.getElementById('l2tp-server-enabled').checked; const port = parseInt(document.getElementById('l2tp-server-port').value) || 1701; const profile = document.getElementById('l2tp-server-profile').value; const useipsec = document.getElementById('l2tp-server-useipsec').value; const psk = document.getElementById('l2tp-server-ipsecsecret').value.trim(); try { const res = await fetch(`${API_BASE}/vpn/servers/l2tp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, port, default_profile: profile, use_ipsec: useipsec, ipsec_secret: psk }) }); if (res.ok) { showToast('L2TP Server', enabled ? 'L2TP Server started' : 'L2TP Server stopped', 'success'); loadPPPServers(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // IPSec Server Modal function openIPSecServerModal() { loadPPPServers(); document.getElementById('ipsec-server-modal').classList.remove('hidden'); } function closeIPSecServerModal() { document.getElementById('ipsec-server-modal').classList.add('hidden'); } async function submitSaveIPSecServer(e) { e.preventDefault(); const enabled = document.getElementById('modal-ipsec-server-enabled').checked; const psk = document.getElementById('modal-ipsec-server-psk').value.trim(); const pool = document.getElementById('modal-ipsec-server-pool').value.trim(); const dns = document.getElementById('modal-ipsec-server-dns').value.trim(); const subnet = document.getElementById('modal-ipsec-server-subnet').value.trim(); try { const res = await fetch(`${API_BASE}/vpn/servers/ipsec`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, preshared_key: psk, pool_address: pool, dns_servers: dns, local_subnet: subnet }) }); if (res.ok) { showToast('IPSec Server', enabled ? 'IKEv2 Roadwarrior responder active' : 'IPSec Server stopped', 'success'); closeIPSecServerModal(); loadPPPServers(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // WireGuard Client Config Viewer let currentWGClientConf = ''; async function openWGClientConfModal(iface, clientIP) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/client-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, address: clientIP || '10.10.0.2/32', dns: '1.1.1.1, 8.8.8.8' }) }); if (res.ok) { const data = await res.json(); currentWGClientConf = data.config; document.getElementById('wg-client-conf-text').value = currentWGClientConf; document.getElementById('wg-client-conf-modal').classList.remove('hidden'); } else { const err = await res.json(); showToast('Error', err.message, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function closeWGClientConfModal() { document.getElementById('wg-client-conf-modal').classList.add('hidden'); } function copyWGClientConf() { if (!currentWGClientConf) return; copyToClipboard(currentWGClientConf, 'WireGuard Client Profile'); } function downloadWGClientConf() { if (!currentWGClientConf) return; const blob = new Blob([currentWGClientConf], { type: 'text/plain;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'wireguard-client.conf'; document.body.appendChild(a); a.click(); document.body.removeChild(a); showToast('Downloaded', 'wireguard-client.conf saved', 'success'); } // ========================================================================= // DYNAMIC ROUTING (FRRouting BGP & OSPFv2) // ========================================================================= state.activeDynSubTab = 'bgp-peers'; state.activeBgpSubTab = 'bgp-peers'; state.activeOspfSubTab = 'ospf-ifaces'; state.bgpInstance = null; state.bgpPeers = []; state.bgpNets = []; state.bgpRoutes = []; state.ospfInstance = null; state.ospfInterfaces = []; state.ospfAreas = []; state.ospfNeighbors = []; state.ospfRoutes = []; function switchBgpSubTab(subTab) { state.activeBgpSubTab = subTab; const tabs = ['bgp-peers', 'bgp-nets', 'bgp-routes']; tabs.forEach(t => { const el = document.getElementById(`dyn-subtab-${t}`); const btn = document.getElementById(`bgp-subtab-btn-${t}`); if (el) el.classList.toggle('hidden', t !== subTab); if (btn) { if (t === subTab) { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-purple-500 text-white transition flex items-center gap-2 whitespace-nowrap'; } else { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap'; } } }); } function switchOspfSubTab(subTab) { state.activeOspfSubTab = subTab; const tabs = ['ospf-ifaces', 'ospf-areas', 'ospf-nbrs', 'ospf-routes']; tabs.forEach(t => { const el = document.getElementById(`dyn-subtab-${t}`); const btn = document.getElementById(`ospf-subtab-btn-${t}`); if (el) el.classList.toggle('hidden', t !== subTab); if (btn) { if (t === subTab) { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-indigo-500 text-white transition flex items-center gap-2 whitespace-nowrap'; } else { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap'; } } }); } function switchDynSubTab(subTab) { if (subTab.startsWith('bgp')) { if (state.activeTab !== 'bgp') navigate('bgp'); switchBgpSubTab(subTab); } else if (subTab.startsWith('ospf')) { if (state.activeTab !== 'ospf') navigate('ospf'); switchOspfSubTab(subTab); } } async function loadDynamicRouting(showToastMsg = false) { try { const fetchSafeJson = async (url) => { try { const r = await fetch(url); return r.ok ? await r.json() : null; } catch (e) { return null; } }; const [sum, bgpInst, bgpPeers, bgpNets, bgpRoutes, ospfInst, ospfIfaces, ospfAreas, ospfNbrs, ospfRoutes, filters] = await Promise.all([ fetchSafeJson(`${API_BASE}/routing/dynamic/summary`), fetchSafeJson(`${API_BASE}/routing/bgp/instance`), fetchSafeJson(`${API_BASE}/routing/bgp/peers`), fetchSafeJson(`${API_BASE}/routing/bgp/networks`), fetchSafeJson(`${API_BASE}/routing/bgp/routes`), fetchSafeJson(`${API_BASE}/routing/ospf/instance`), fetchSafeJson(`${API_BASE}/routing/ospf/interfaces`), fetchSafeJson(`${API_BASE}/routing/ospf/areas`), fetchSafeJson(`${API_BASE}/routing/ospf/neighbors`), fetchSafeJson(`${API_BASE}/routing/ospf/routes`), fetchSafeJson(`${API_BASE}/routing/filters`) ]); if (sum) state.dynSummary = sum; if (bgpInst) state.bgpInstance = bgpInst; if (bgpPeers) state.bgpPeers = bgpPeers; if (bgpNets) state.bgpNets = bgpNets; if (bgpRoutes) state.bgpRoutes = bgpRoutes; if (ospfInst) state.ospfInstance = ospfInst; if (ospfIfaces) state.ospfInterfaces = ospfIfaces; if (ospfAreas) state.ospfAreas = ospfAreas; if (ospfNbrs) state.ospfNeighbors = ospfNbrs; if (ospfRoutes) state.ospfRoutes = ospfRoutes; if (filters) state.routingFilters = filters; try { renderBgpStatusPill(); } catch(e) { console.error('renderBgpStatusPill error:', e); } try { renderBgpPeers(); } catch(e) { console.error('renderBgpPeers error:', e); } try { renderBgpNetworks(); } catch(e) { console.error('renderBgpNetworks error:', e); } try { renderBgpRoutes(); } catch(e) { console.error('renderBgpRoutes error:', e); } try { renderOspfStatusPill(); } catch(e) { console.error('renderOspfStatusPill error:', e); } try { renderOspfInterfaces(); } catch(e) { console.error('renderOspfInterfaces error:', e); } try { renderOspfAreas(); } catch(e) { console.error('renderOspfAreas error:', e); } try { renderOspfNeighbors(); } catch(e) { console.error('renderOspfNeighbors error:', e); } try { renderOspfRoutes(); } catch(e) { console.error('renderOspfRoutes error:', e); } try { renderRoutingFilters(); } catch(e) { console.error('renderRoutingFilters error:', e); } try { updateBadges(); } catch(e) { console.error('updateBadges error:', e); } if (showToastMsg) showToast('Refreshed', 'Dynamic routing telemetry updated', 'info'); } catch (err) { console.error('Failed to load dynamic routing:', err); if (showToastMsg) showToast('Notice', 'Unable to refresh dynamic routing telemetry', 'warning'); } } function renderBgpStatusPill() { const pill = document.getElementById('bgp-live-status-pill'); if (!pill) return; const inst = state.bgpInstance; if (!inst || !inst.enabled) { pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700'; pill.innerText = 'BGP DISABLED'; } else if (inst.running) { pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-purple-950 text-purple-300 border border-purple-800/60 font-semibold'; pill.innerText = `BGP ACTIVE (AS ${inst.as})`; } else { pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold'; pill.innerText = 'BGP STARTING...'; } } function renderBgpPeers() { const tbody = document.getElementById('bgp-peers-table-body'); if (!tbody) return; const empty = document.getElementById('bgp-peers-empty'); const countBadge = document.getElementById('bgp-peers-count-badge'); const navBadge = document.getElementById('dyn-badge-bgp-peers'); const peers = state.bgpPeers || []; if (countBadge) countBadge.innerText = `${peers.length} Peers`; if (navBadge) navBadge.innerText = peers.length; if (peers.length === 0) { tbody.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; } if (empty) empty.classList.add('hidden'); tbody.innerHTML = peers.map(p => { let flagA = !p.disabled ? 'A' : ''; let flagE = p.state === 'Established' ? 'E' : ''; let flagX = p.disabled ? 'X' : ''; let stateBadge = ''; if (p.disabled) { stateBadge = 'Disabled'; } else if (p.state === 'Established') { stateBadge = 'Established'; } else if (p.state === 'Connect' || p.state === 'Active') { stateBadge = `${p.state}`; } else { stateBadge = `${p.state || 'Idle'}`; } return ` ${flagA}${flagE}${flagX} ${escapeHtml(p.name)} ${escapeHtml(p.remote_address)} AS ${p.remote_as} ${stateBadge} ${escapeHtml(p.uptime || '-')} ↓ ${p.prefix_received || 0} / ↑ ${p.prefix_sent || 0} ${p.in_filter ? `IN: ${escapeHtml(p.in_filter)} ` : ''} ${p.out_filter ? `OUT: ${escapeHtml(p.out_filter)}` : ''} ${!p.in_filter && !p.out_filter ? '-' : ''} ${escapeHtml(p.comment || '')} `; }).join(''); } function renderBgpNetworks() { const tbody = document.getElementById('bgp-nets-table-body'); if (!tbody) return; const empty = document.getElementById('bgp-nets-empty'); const countBadge = document.getElementById('bgp-nets-count-badge'); const navBadge = document.getElementById('dyn-badge-bgp-nets'); const nets = state.bgpNets || []; if (countBadge) countBadge.innerText = `${nets.length} Networks`; if (navBadge) navBadge.innerText = nets.length; if (nets.length === 0) { tbody.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; } if (empty) empty.classList.add('hidden'); tbody.innerHTML = nets.map(n => ` ${escapeHtml(n.network)} ${n.disabled ? 'Disabled' : 'Announcing'} ${escapeHtml(n.comment || '')} `).join(''); } function renderBgpRoutes() { const tbody = document.getElementById('bgp-routes-table-body'); if (!tbody) return; const empty = document.getElementById('bgp-routes-empty'); const countBadge = document.getElementById('bgp-routes-count-badge'); const navBadge = document.getElementById('dyn-badge-bgp-routes'); const routes = state.bgpRoutes || []; if (countBadge) countBadge.innerText = `${routes.length} Routes`; if (navBadge) navBadge.innerText = routes.length; if (routes.length === 0) { tbody.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; } if (empty) empty.classList.add('hidden'); tbody.innerHTML = routes.map(r => ` ${escapeHtml(r.prefix)} ${escapeHtml(r.next_hop || '-')} ${r.metric} ${r.weight} ${escapeHtml(r.path || 'i')} ${escapeHtml(r.origin || 'IGP')} `).join(''); } function renderOspfStatusPill() { const pill = document.getElementById('ospf-live-status-pill'); if (!pill) return; const inst = state.ospfInstance; if (!inst || !inst.enabled) { pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700'; pill.innerText = 'OSPF DISABLED'; } else if (inst.running) { pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-indigo-950 text-indigo-300 border border-indigo-800/60 font-semibold'; pill.innerText = `OSPF ACTIVE (ID: ${inst.router_id || 'Auto'})`; } else { pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold'; pill.innerText = 'OSPF STARTING...'; } } function renderOspfInterfaces() { const tbody = document.getElementById('ospf-ifaces-table-body'); if (!tbody) return; const empty = document.getElementById('ospf-ifaces-empty'); const countBadge = document.getElementById('ospf-ifaces-count-badge'); const navBadge = document.getElementById('dyn-badge-ospf-ifaces'); const ifaces = state.ospfInterfaces || []; if (countBadge) countBadge.innerText = `${ifaces.length} Interfaces`; if (navBadge) navBadge.innerText = ifaces.length; if (ifaces.length === 0) { tbody.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; } if (empty) empty.classList.add('hidden'); tbody.innerHTML = ifaces.map(i => { let flagA = !i.disabled ? 'A' : ''; let flagP = i.passive ? 'P' : ''; let flagX = i.disabled ? 'X' : ''; return ` ${flagA}${flagP}${flagX} ${escapeHtml(i.network)} Area ${escapeHtml(i.area)} ${escapeHtml(i.interface || 'Any')} ${i.cost} ${i.priority} ${i.hello_interval}s / ${i.dead_interval}s ${escapeHtml(i.auth_type || 'none')} ${escapeHtml(i.comment || '')} `; }).join(''); } function renderOspfAreas() { const tbody = document.getElementById('ospf-areas-table-body'); if (!tbody) return; const countBadge = document.getElementById('ospf-areas-count-badge'); const navBadge = document.getElementById('dyn-badge-ospf-areas'); const areas = state.ospfAreas || []; if (countBadge) countBadge.innerText = `${areas.length} Areas`; if (navBadge) navBadge.innerText = areas.length; tbody.innerHTML = areas.map(a => ` ${escapeHtml(a.name)} ${escapeHtml(a.area_id)} ${escapeHtml(a.area_type || 'default')} ${escapeHtml(a.comment || '')} `).join(''); } function renderOspfNeighbors() { const tbody = document.getElementById('ospf-nbrs-table-body'); if (!tbody) return; const empty = document.getElementById('ospf-nbrs-empty'); const countBadge = document.getElementById('ospf-nbrs-count-badge'); const navBadge = document.getElementById('dyn-badge-ospf-nbrs'); const nbrs = state.ospfNeighbors || []; if (countBadge) countBadge.innerText = `${nbrs.length} Neighbors`; if (navBadge) navBadge.innerText = nbrs.length; if (nbrs.length === 0) { tbody.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; } if (empty) empty.classList.add('hidden'); tbody.innerHTML = nbrs.map(n => { let stateBadge = ''; if (n.state && (n.state.startsWith('Full') || n.state.includes('DR') || n.state.includes('Backup'))) { stateBadge = `${escapeHtml(n.state)}`; } else { stateBadge = `${escapeHtml(n.state || 'Unknown')}`; } return ` ${escapeHtml(n.neighbor_id)} ${escapeHtml(n.address)} ${escapeHtml(n.interface)} ${escapeHtml(n.area || '0.0.0.0')} ${stateBadge} ${escapeHtml(n.dead_timer || '-')} `; }).join(''); } function renderOspfRoutes() { const tbody = document.getElementById('ospf-routes-table-body'); if (!tbody) return; const empty = document.getElementById('ospf-routes-empty'); const countBadge = document.getElementById('ospf-routes-count-badge'); const navBadge = document.getElementById('dyn-badge-ospf-routes'); const routes = state.ospfRoutes || []; if (countBadge) countBadge.innerText = `${routes.length} Routes`; if (navBadge) navBadge.innerText = routes.length; if (routes.length === 0) { tbody.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; } if (empty) empty.classList.add('hidden'); tbody.innerHTML = routes.map(r => ` ${escapeHtml(r.prefix)} ${escapeHtml(r.route_type || 'Network')} ${r.cost} Area ${escapeHtml(r.area || '0.0.0.0')} ${escapeHtml(r.next_hop || r.interface || '-')} `).join(''); } // BGP Modals & Actions function openBgpInstanceModal() { const inst = state.bgpInstance || { enabled: false, as: 65001, router_id: '192.168.131.56', hold_time: 180, keepalive_time: 60 }; document.getElementById('modal-bgp-enabled').checked = !!inst.enabled; document.getElementById('modal-bgp-as').value = inst.as || 65001; document.getElementById('modal-bgp-router-id').value = inst.router_id || ''; document.getElementById('modal-bgp-keepalive').value = inst.keepalive_time || 60; document.getElementById('modal-bgp-holdtime').value = inst.hold_time || 180; document.getElementById('modal-bgp-redist-conn').checked = !!inst.redistribute_connected; document.getElementById('modal-bgp-redist-static').checked = !!inst.redistribute_static; document.getElementById('modal-bgp-redist-ospf').checked = !!inst.redistribute_ospf; document.getElementById('bgp-instance-modal').classList.remove('hidden'); } function closeBgpInstanceModal() { document.getElementById('bgp-instance-modal').classList.add('hidden'); } async function submitBgpInstance(e) { e.preventDefault(); const payload = { enabled: document.getElementById('modal-bgp-enabled').checked, as: parseInt(document.getElementById('modal-bgp-as').value, 10), router_id: document.getElementById('modal-bgp-router-id').value.trim(), keepalive_time: parseInt(document.getElementById('modal-bgp-keepalive').value, 10), hold_time: parseInt(document.getElementById('modal-bgp-holdtime').value, 10), redistribute_connected: document.getElementById('modal-bgp-redist-conn').checked, redistribute_static: document.getElementById('modal-bgp-redist-static').checked, redistribute_ospf: document.getElementById('modal-bgp-redist-ospf').checked }; try { const res = await fetch(`${API_BASE}/routing/bgp/instance`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeBgpInstanceModal(); showToast('Success', 'BGP instance configuration updated', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to update BGP instance', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function openAddBgpPeerModal() { document.getElementById('bgp-peer-form').reset(); document.getElementById('modal-bgp-peer-id').value = ''; document.getElementById('bgp-peer-modal-title').innerText = 'Add BGP Peer Connection'; document.getElementById('modal-bgp-peer-nexthop-self').checked = true; const inFilterEl = document.getElementById('modal-bgp-peer-in-filter'); if (inFilterEl) inFilterEl.value = ''; const outFilterEl = document.getElementById('modal-bgp-peer-out-filter'); if (outFilterEl) outFilterEl.value = ''; document.getElementById('bgp-peer-modal').classList.remove('hidden'); } function openEditBgpPeerModal(id) { const p = (state.bgpPeers || []).find(item => item.id === id); if (!p) return; document.getElementById('modal-bgp-peer-id').value = p.id; document.getElementById('modal-bgp-peer-name').value = p.name || ''; document.getElementById('modal-bgp-peer-remote-addr').value = p.remote_address || ''; document.getElementById('modal-bgp-peer-remote-as').value = p.remote_as || ''; document.getElementById('modal-bgp-peer-local-addr').value = p.local_address || ''; document.getElementById('modal-bgp-peer-password').value = p.password || ''; document.getElementById('modal-bgp-peer-multihop').checked = !!p.multihop; document.getElementById('modal-bgp-peer-nexthop-self').checked = !!p.nexthop_self; document.getElementById('modal-bgp-peer-passive').checked = !!p.passive; document.getElementById('modal-bgp-peer-disabled').checked = !!p.disabled; document.getElementById('modal-bgp-peer-comment').value = p.comment || ''; const inFilterEl = document.getElementById('modal-bgp-peer-in-filter'); if (inFilterEl) inFilterEl.value = p.in_filter || ''; const outFilterEl = document.getElementById('modal-bgp-peer-out-filter'); if (outFilterEl) outFilterEl.value = p.out_filter || ''; document.getElementById('bgp-peer-modal-title').innerText = 'Edit BGP Peer Connection'; document.getElementById('bgp-peer-modal').classList.remove('hidden'); } function closeBgpPeerModal() { document.getElementById('bgp-peer-modal').classList.add('hidden'); } async function submitBgpPeer(e) { e.preventDefault(); const payload = { id: document.getElementById('modal-bgp-peer-id').value, name: document.getElementById('modal-bgp-peer-name').value.trim(), remote_address: document.getElementById('modal-bgp-peer-remote-addr').value.trim(), remote_as: parseInt(document.getElementById('modal-bgp-peer-remote-as').value, 10), local_address: document.getElementById('modal-bgp-peer-local-addr').value.trim(), password: document.getElementById('modal-bgp-peer-password').value, multihop: document.getElementById('modal-bgp-peer-multihop').checked, nexthop_self: document.getElementById('modal-bgp-peer-nexthop-self').checked, passive: document.getElementById('modal-bgp-peer-passive').checked, disabled: document.getElementById('modal-bgp-peer-disabled').checked, in_filter: (document.getElementById('modal-bgp-peer-in-filter')?.value || '').trim(), out_filter: (document.getElementById('modal-bgp-peer-out-filter')?.value || '').trim(), comment: document.getElementById('modal-bgp-peer-comment').value.trim() }; try { const res = await fetch(`${API_BASE}/routing/bgp/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeBgpPeerModal(); showToast('Success', 'BGP Peer saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save peer', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteBgpPeer(id) { if (!confirm('Are you sure you want to delete this BGP peer connection?')) return; try { const res = await fetch(`${API_BASE}/routing/bgp/peers/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'BGP peer removed', 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleBgpPeerState(id, disabled) { try { const res = await fetch(`${API_BASE}/routing/bgp/peers/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast('Updated', `Peer ${disabled ? 'disabled' : 'enabled'}`, 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } function openAddBgpNetworkModal() { document.getElementById('bgp-network-form').reset(); document.getElementById('modal-bgp-net-id').value = ''; document.getElementById('bgp-network-modal').classList.remove('hidden'); } function closeBgpNetworkModal() { document.getElementById('bgp-network-modal').classList.add('hidden'); } async function submitBgpNetwork(e) { e.preventDefault(); const payload = { id: document.getElementById('modal-bgp-net-id').value, network: document.getElementById('modal-bgp-net-cidr').value.trim(), comment: document.getElementById('modal-bgp-net-comment').value.trim(), disabled: document.getElementById('modal-bgp-net-disabled').checked }; try { const res = await fetch(`${API_BASE}/routing/bgp/networks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeBgpNetworkModal(); showToast('Success', 'BGP network advertisement saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save network', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteBgpNetwork(id) { if (!confirm('Are you sure you want to stop announcing this network?')) return; try { const res = await fetch(`${API_BASE}/routing/bgp/networks/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Network advertisement removed', 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } // OSPF Modals & Actions function openOspfInstanceModal() { const inst = state.ospfInstance || { enabled: false, router_id: '192.168.131.56', default_metric: 10 }; document.getElementById('modal-ospf-enabled').checked = !!inst.enabled; document.getElementById('modal-ospf-router-id').value = inst.router_id || ''; document.getElementById('modal-ospf-metric').value = inst.default_metric || 10; document.getElementById('modal-ospf-redist-conn').checked = !!inst.redistribute_connected; document.getElementById('modal-ospf-redist-static').checked = !!inst.redistribute_static; document.getElementById('modal-ospf-redist-bgp').checked = !!inst.redistribute_bgp; document.getElementById('ospf-instance-modal').classList.remove('hidden'); } function closeOspfInstanceModal() { document.getElementById('ospf-instance-modal').classList.add('hidden'); } async function submitOspfInstance(e) { e.preventDefault(); const payload = { enabled: document.getElementById('modal-ospf-enabled').checked, router_id: document.getElementById('modal-ospf-router-id').value.trim(), default_metric: parseInt(document.getElementById('modal-ospf-metric').value, 10), redistribute_connected: document.getElementById('modal-ospf-redist-conn').checked, redistribute_static: document.getElementById('modal-ospf-redist-static').checked, redistribute_bgp: document.getElementById('modal-ospf-redist-bgp').checked }; try { const res = await fetch(`${API_BASE}/routing/ospf/instance`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeOspfInstanceModal(); showToast('Success', 'OSPF instance configuration updated', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to update OSPF instance', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function openAddOspfAreaModal() { document.getElementById('ospf-area-form').reset(); document.getElementById('modal-ospf-area-id').value = ''; document.getElementById('modal-ospf-area-areaid').value = '0.0.0.0'; document.getElementById('ospf-area-modal').classList.remove('hidden'); } function closeOspfAreaModal() { document.getElementById('ospf-area-modal').classList.add('hidden'); } async function submitOspfArea(e) { e.preventDefault(); const payload = { id: document.getElementById('modal-ospf-area-id').value, name: document.getElementById('modal-ospf-area-name').value.trim(), area_id: document.getElementById('modal-ospf-area-areaid').value.trim(), area_type: document.getElementById('modal-ospf-area-type').value, comment: document.getElementById('modal-ospf-area-comment').value.trim() }; try { const res = await fetch(`${API_BASE}/routing/ospf/areas`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeOspfAreaModal(); showToast('Success', 'OSPF area saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save area', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteOspfArea(id) { if (!confirm('Are you sure you want to delete this OSPF area?')) return; try { const res = await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'OSPF area removed', 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } function populateOspfInterfaceDevices() { const select = document.getElementById('modal-ospf-iface-device'); if (!select) return; const current = select.value; select.innerHTML = ''; (state.interfaces || []).forEach(iface => { select.innerHTML += ``; }); if (current) select.value = current; } function openAddOspfInterfaceModal() { document.getElementById('ospf-interface-form').reset(); document.getElementById('modal-ospf-iface-id').value = ''; document.getElementById('modal-ospf-iface-area').value = '0.0.0.0'; document.getElementById('modal-ospf-iface-cost').value = '10'; document.getElementById('modal-ospf-iface-priority').value = '1'; document.getElementById('modal-ospf-iface-hello').value = '10'; document.getElementById('modal-ospf-iface-dead').value = '40'; document.getElementById('ospf-interface-modal-title').innerText = 'Add OSPF Interface'; populateOspfInterfaceDevices(); document.getElementById('ospf-interface-modal').classList.remove('hidden'); } function openEditOspfInterfaceModal(id) { const item = (state.ospfInterfaces || []).find(i => i.id === id); if (!item) return; populateOspfInterfaceDevices(); document.getElementById('modal-ospf-iface-id').value = item.id; document.getElementById('modal-ospf-iface-net').value = item.network || ''; document.getElementById('modal-ospf-iface-area').value = item.area || '0.0.0.0'; document.getElementById('modal-ospf-iface-device').value = item.interface || ''; document.getElementById('modal-ospf-iface-cost').value = item.cost || 10; document.getElementById('modal-ospf-iface-priority').value = item.priority || 1; document.getElementById('modal-ospf-iface-hello').value = item.hello_interval || 10; document.getElementById('modal-ospf-iface-dead').value = item.dead_interval || 40; document.getElementById('modal-ospf-iface-auth-type').value = item.auth_type || 'none'; document.getElementById('modal-ospf-iface-auth-key').value = item.auth_key || ''; document.getElementById('modal-ospf-iface-passive').checked = !!item.passive; document.getElementById('modal-ospf-iface-disabled').checked = !!item.disabled; document.getElementById('modal-ospf-iface-comment').value = item.comment || ''; document.getElementById('ospf-interface-modal-title').innerText = 'Edit OSPF Interface'; document.getElementById('ospf-interface-modal').classList.remove('hidden'); } function closeOspfInterfaceModal() { document.getElementById('ospf-interface-modal').classList.add('hidden'); } async function submitOspfInterface(e) { e.preventDefault(); const payload = { id: document.getElementById('modal-ospf-iface-id').value, network: document.getElementById('modal-ospf-iface-net').value.trim(), area: document.getElementById('modal-ospf-iface-area').value.trim(), interface: document.getElementById('modal-ospf-iface-device').value, cost: parseInt(document.getElementById('modal-ospf-iface-cost').value, 10), priority: parseInt(document.getElementById('modal-ospf-iface-priority').value, 10), hello_interval: parseInt(document.getElementById('modal-ospf-iface-hello').value, 10), dead_interval: parseInt(document.getElementById('modal-ospf-iface-dead').value, 10), auth_type: document.getElementById('modal-ospf-iface-auth-type').value, auth_key: document.getElementById('modal-ospf-iface-auth-key').value, passive: document.getElementById('modal-ospf-iface-passive').checked, disabled: document.getElementById('modal-ospf-iface-disabled').checked, comment: document.getElementById('modal-ospf-iface-comment').value.trim() }; try { const res = await fetch(`${API_BASE}/routing/ospf/interfaces`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeOspfInterfaceModal(); showToast('Success', 'OSPF interface template saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save interface', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteOspfInterface(id) { if (!confirm('Are you sure you want to delete this OSPF interface?')) return; try { const res = await fetch(`${API_BASE}/routing/ospf/interfaces/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'OSPF interface removed', 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleOspfInterfaceState(id, disabled) { try { const res = await fetch(`${API_BASE}/routing/ospf/interfaces/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast('Updated', `Interface ${disabled ? 'disabled' : 'enabled'}`, 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } // ============================================================================ // ==================== MIKROTIK BRIDGE SUBSYSTEM ============================== // ============================================================================ function switchBridgeSubTab(tab) { state.bridgeSubTab = tab; const tabs = ['bridges', 'ports', 'vlans', 'hosts']; tabs.forEach(t => { const panel = document.getElementById(`bridge-subtab-${t}`); const btn = document.getElementById(`bridge-subtab-btn-${t}`); if (panel) { if (t === tab) panel.classList.remove('hidden'); else panel.classList.add('hidden'); } if (btn) { if (t === tab) { btn.className = 'px-4 py-2 text-xs font-bold rounded-lg bg-slate-800 text-white shadow-sm transition flex items-center gap-2'; } else { btn.className = 'px-4 py-2 text-xs font-bold rounded-lg text-slate-400 hover:text-slate-200 transition flex items-center gap-2'; } } }); const addBtn = document.getElementById('bridge-top-action-btn'); if (addBtn) { if (tab === 'bridges') addBtn.innerHTML = '+ Add Bridge'; else if (tab === 'ports') addBtn.innerHTML = '+ Add Port'; else if (tab === 'vlans') addBtn.innerHTML = '+ Add VLAN Entry'; else if (tab === 'hosts') addBtn.innerHTML = '🔄 Refresh MACs'; } if (tab === 'hosts') loadBridgeHosts(); } function openCurrentBridgeModal() { if (state.bridgeSubTab === 'bridges') openAddBridgeModal(); else if (state.bridgeSubTab === 'ports') openAddBridgePortModal(); else if (state.bridgeSubTab === 'vlans') openAddBridgeVLANModal(); else if (state.bridgeSubTab === 'hosts') loadBridgeHosts(); } async function loadBridge() { try { const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vlanIfacesRes] = await Promise.all([ fetch(`${API_BASE}/bridge/bridges`), fetch(`${API_BASE}/bridge/ports`), fetch(`${API_BASE}/bridge/vlans`), fetch(`${API_BASE}/bridge/summary`), fetch(`${API_BASE}/vpn/eoip`), fetch(`${API_BASE}/vlans`) ]); if (bridgesRes.ok) state.bridges = await bridgesRes.json(); if (portsRes.ok) state.bridgePorts = await portsRes.json(); if (vlansRes.ok) state.bridgeVLANs = await vlansRes.json(); if (summaryRes.ok) state.bridgeSummary = await summaryRes.json(); if (eoipRes && eoipRes.ok) state.eoipTunnels = await eoipRes.json(); if (vlanIfacesRes && vlanIfacesRes.ok) state.vlans = await vlanIfacesRes.json(); // Badges & Stats const bBridges = document.getElementById('bridge-badge-bridges'); if (bBridges) bBridges.innerText = (state.bridges || []).length; const bPorts = document.getElementById('bridge-badge-ports'); if (bPorts) bPorts.innerText = (state.bridgePorts || []).length; const bVlans = document.getElementById('bridge-badge-vlans'); if (bVlans) bVlans.innerText = (state.bridgeVLANs || []).length; const bHosts = document.getElementById('bridge-badge-hosts'); if (bHosts) bHosts.innerText = state.bridgeSummary?.hosts_count || 0; const sBr = document.getElementById('bridge-stat-bridges'); if (sBr) sBr.innerText = (state.bridges || []).length; const sPorts = document.getElementById('bridge-stat-ports'); if (sPorts) sPorts.innerText = (state.bridgePorts || []).length; const sHosts = document.getElementById('bridge-stat-hosts'); if (sHosts) sHosts.innerText = state.bridgeSummary?.hosts_count || 0; renderBridges(); renderBridgePorts(); renderBridgeVLANs(); if (state.bridgeSubTab === 'hosts') loadBridgeHosts(); } catch (err) { console.error('Failed to load bridge subsystem:', err); } } // ----------------- Bridges ----------------- function renderBridges() { const tbody = document.getElementById('bridge-table-body'); const empty = document.getElementById('bridge-empty-bridges'); const countBadge = document.getElementById('bridge-count-badge'); if (!tbody) return; tbody.innerHTML = ''; const bridges = state.bridges || []; if (countBadge) countBadge.innerText = `${bridges.length} Bridges`; if (bridges.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); bridges.forEach((b, idx) => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${b.disabled ? 'opacity-50' : ''}`; // Format IP addresses pill list let ipsHtml = 'None'; if (b.ip_addresses && b.ip_addresses.length > 0) { ipsHtml = b.ip_addresses.map(ip => ` ${ip} `).join(' '); } const runningFlag = b.running ? 'R' : '-'; const disabledFlag = b.disabled ? 'X' : ''; const flagDisplay = `${runningFlag}${disabledFlag ? ' ' + disabledFlag : ''}`; tr.innerHTML = ` ${idx + 1} ${flagDisplay} 🌉${b.name} ${b.mtu} ${b.mac_address || '-'} ${b.stp} ${b.vlan_filtering ? 'yes' : 'no'} ${b.pvid || 1} ${b.ports_count}
${ipsHtml}
↓ ${b.rx_human || '0 B'} / ↑ ${b.tx_human || '0 B'} ${b.comment || '-'} `; tbody.appendChild(tr); }); } function openAddBridgeModal() { document.getElementById('bridge-modal-title').innerText = 'Add Bridge Interface'; document.getElementById('bridge-modal-id').value = ''; document.getElementById('bridge-modal-name').value = 'bridge1'; document.getElementById('bridge-modal-name').disabled = false; document.getElementById('bridge-modal-mtu').value = '1500'; document.getElementById('bridge-modal-mac').value = ''; document.getElementById('bridge-modal-stp').value = 'rstp'; document.getElementById('bridge-modal-priority').value = '32768'; document.getElementById('bridge-modal-vlanfiltering').checked = false; document.getElementById('bridge-modal-pvid').value = '1'; document.getElementById('bridge-modal-fastforward').checked = true; document.getElementById('bridge-modal-comment').value = ''; document.getElementById('bridge-modal-disabled').checked = false; toggleBridgeModalVlanFiltering(); document.getElementById('bridge-modal').classList.remove('hidden'); } function openEditBridgeModal(id) { const b = (state.bridges || []).find(x => x.id === id); if (!b) return; document.getElementById('bridge-modal-title').innerText = `Edit Bridge (${b.name})`; document.getElementById('bridge-modal-id').value = b.id; document.getElementById('bridge-modal-name').value = b.name; document.getElementById('bridge-modal-name').disabled = true; document.getElementById('bridge-modal-mtu').value = b.mtu || 1500; document.getElementById('bridge-modal-mac').value = b.mac_address || ''; document.getElementById('bridge-modal-stp').value = b.stp || 'rstp'; document.getElementById('bridge-modal-priority').value = b.priority || 32768; document.getElementById('bridge-modal-vlanfiltering').checked = !!b.vlan_filtering; document.getElementById('bridge-modal-pvid').value = b.pvid || 1; document.getElementById('bridge-modal-fastforward').checked = b.fast_forward !== false; document.getElementById('bridge-modal-comment').value = b.comment || ''; document.getElementById('bridge-modal-disabled').checked = !!b.disabled; toggleBridgeModalVlanFiltering(); document.getElementById('bridge-modal').classList.remove('hidden'); } function closeBridgeModal() { document.getElementById('bridge-modal').classList.add('hidden'); } function toggleBridgeModalVlanFiltering() { const chk = document.getElementById('bridge-modal-vlanfiltering').checked; const pvidBox = document.getElementById('bridge-modal-pvid-box'); if (pvidBox) { if (chk) pvidBox.classList.remove('opacity-40'); else pvidBox.classList.add('opacity-40'); } } async function submitSaveBridge(e) { e.preventDefault(); const id = document.getElementById('bridge-modal-id').value; const name = document.getElementById('bridge-modal-name').value.trim(); const mtu = parseInt(document.getElementById('bridge-modal-mtu').value, 10) || 1500; const mac = document.getElementById('bridge-modal-mac').value.trim(); const stp = document.getElementById('bridge-modal-stp').value; const priority = parseInt(document.getElementById('bridge-modal-priority').value, 10) || 32768; const vlanFiltering = document.getElementById('bridge-modal-vlanfiltering').checked; const pvid = parseInt(document.getElementById('bridge-modal-pvid').value, 10) || 1; const fastForward = document.getElementById('bridge-modal-fastforward').checked; const comment = document.getElementById('bridge-modal-comment').value.trim(); const disabled = document.getElementById('bridge-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/bridge/bridges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, mtu, mac_address: mac, stp, priority, vlan_filtering: vlanFiltering, pvid, fast_forward: fastForward, comment, disabled }) }); if (res.ok) { showToast('Bridge Saved', `Bridge ${name} configured`, 'success'); closeBridgeModal(); loadBridge(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to save bridge', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleBridgeState(id, disabled) { try { const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { loadBridge(); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteBridge(id) { const b = (state.bridges || []).find(x => x.id === id); const name = b ? b.name : id; if (!confirm(`Are you sure you want to delete bridge '${name}'? All member ports will be detached.`)) return; try { const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `Bridge ${name} deleted`, 'success'); loadBridge(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to delete bridge', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Bridge IP Assignments ----------------- function openAddBridgeIPModal(id, name) { document.getElementById('bridge-ip-modal-id').value = id; document.getElementById('bridge-ip-modal-sub').innerText = name; document.getElementById('bridge-ip-modal-cidr').value = ''; document.getElementById('bridge-ip-modal').classList.remove('hidden'); } function closeBridgeIPModal() { document.getElementById('bridge-ip-modal').classList.add('hidden'); } async function submitAddBridgeIP(e) { e.preventDefault(); const id = document.getElementById('bridge-ip-modal-id').value; const ip = document.getElementById('bridge-ip-modal-cidr').value.trim(); try { const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip }) }); if (res.ok) { showToast('IP Assigned', `${ip} assigned to bridge`, 'success'); closeBridgeIPModal(); loadBridge(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to add IP', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteBridgeIP(id, ip) { if (!confirm(`Remove IP address ${ip} from bridge?`)) return; try { const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip }) }); if (res.ok) { showToast('IP Removed', `${ip} removed from bridge`, 'success'); loadBridge(); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Bridge Ports ----------------- function renderBridgePorts() { const tbody = document.getElementById('bridge-ports-table-body'); const empty = document.getElementById('bridge-empty-ports'); const countBadge = document.getElementById('bridge-ports-count-badge'); const filterBridge = document.getElementById('bridge-ports-filter-bridge')?.value || ''; if (!tbody) return; tbody.innerHTML = ''; // Update filter select populateBridgeSelect('bridge-ports-filter-bridge', filterBridge, true); const ports = (state.bridgePorts || []).filter(p => !filterBridge || p.bridge === filterBridge); if (countBadge) countBadge.innerText = `${ports.length} Ports`; if (ports.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); ports.forEach((p, idx) => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${p.disabled ? 'opacity-50' : ''}`; const disabledFlag = p.disabled ? 'X' : '-'; tr.innerHTML = ` ${idx + 1} ${disabledFlag} 🔌${p.interface} ${p.bridge} ${p.pvid || 1} ${p.frame_types} ${p.ingress_filtering ? 'yes' : 'no'} ${p.horizon > 0 ? `domain ${p.horizon}` : 'none'} ${p.status} ${p.comment || '-'} `; tbody.appendChild(tr); }); } function openAddBridgePortModal() { document.getElementById('bridge-port-modal-title').innerText = 'Add Port to Bridge'; document.getElementById('bridge-port-modal-id').value = ''; populateBridgeSelect('bridge-port-modal-bridge'); populateBridgePortCandidateSelect('bridge-port-modal-interface'); document.getElementById('bridge-port-modal-pvid').value = '1'; document.getElementById('bridge-port-modal-frametypes').value = 'admit-all'; document.getElementById('bridge-port-modal-ingressfiltering').checked = false; document.getElementById('bridge-port-modal-horizon').value = '0'; document.getElementById('bridge-port-modal-pathcost').value = '10'; document.getElementById('bridge-port-modal-priority').value = '128'; document.getElementById('bridge-port-modal-learn').checked = true; document.getElementById('bridge-port-modal-comment').value = ''; document.getElementById('bridge-port-modal-disabled').checked = false; document.getElementById('bridge-port-modal').classList.remove('hidden'); } function openEditBridgePortModal(id) { const p = (state.bridgePorts || []).find(x => x.id === id); if (!p) return; document.getElementById('bridge-port-modal-title').innerText = `Edit Bridge Port (${p.interface})`; document.getElementById('bridge-port-modal-id').value = p.id; populateBridgeSelect('bridge-port-modal-bridge', p.bridge); populateBridgePortCandidateSelect('bridge-port-modal-interface', p.interface); document.getElementById('bridge-port-modal-pvid').value = p.pvid || 1; document.getElementById('bridge-port-modal-frametypes').value = p.frame_types || 'admit-all'; document.getElementById('bridge-port-modal-ingressfiltering').checked = !!p.ingress_filtering; document.getElementById('bridge-port-modal-horizon').value = p.horizon || 0; document.getElementById('bridge-port-modal-pathcost').value = p.path_cost || 10; document.getElementById('bridge-port-modal-priority').value = p.priority || 128; document.getElementById('bridge-port-modal-learn').checked = p.learn !== false; document.getElementById('bridge-port-modal-comment').value = p.comment || ''; document.getElementById('bridge-port-modal-disabled').checked = !!p.disabled; document.getElementById('bridge-port-modal').classList.remove('hidden'); } function closeBridgePortModal() { document.getElementById('bridge-port-modal').classList.add('hidden'); } async function submitSaveBridgePort(e) { e.preventDefault(); const id = document.getElementById('bridge-port-modal-id').value; const bridge = document.getElementById('bridge-port-modal-bridge').value; const iface = document.getElementById('bridge-port-modal-interface').value; const pvid = parseInt(document.getElementById('bridge-port-modal-pvid').value, 10) || 1; const frameTypes = document.getElementById('bridge-port-modal-frametypes').value; const ingressFiltering = document.getElementById('bridge-port-modal-ingressfiltering').checked; const horizon = parseInt(document.getElementById('bridge-port-modal-horizon').value, 10) || 0; const pathCost = parseInt(document.getElementById('bridge-port-modal-pathcost').value, 10) || 10; const priority = parseInt(document.getElementById('bridge-port-modal-priority').value, 10) || 128; const learn = document.getElementById('bridge-port-modal-learn').checked; const comment = document.getElementById('bridge-port-modal-comment').value.trim(); const disabled = document.getElementById('bridge-port-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/bridge/ports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, bridge, interface: iface, pvid, frame_types: frameTypes, ingress_filtering: ingressFiltering, horizon, path_cost: pathCost, priority, learn, comment, disabled }) }); if (res.ok) { showToast('Port Attached', `${iface} added to ${bridge}`, 'success'); closeBridgePortModal(); loadBridge(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to save port', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleBridgePortState(id, disabled) { try { const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { loadBridge(); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteBridgePort(id) { const p = (state.bridgePorts || []).find(x => x.id === id); const iface = p ? p.interface : id; if (!confirm(`Detach interface '${iface}' from bridge?`)) return; try { const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Port Detached', `${iface} removed from bridge`, 'success'); loadBridge(); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Bridge VLANs ----------------- function renderBridgeVLANs() { const tbody = document.getElementById('bridge-vlans-table-body'); const empty = document.getElementById('bridge-empty-vlans'); const countBadge = document.getElementById('bridge-vlans-count-badge'); const filterBridge = document.getElementById('bridge-vlans-filter-bridge')?.value || ''; if (!tbody) return; tbody.innerHTML = ''; populateBridgeSelect('bridge-vlans-filter-bridge', filterBridge, true); const vlans = (state.bridgeVLANs || []).filter(v => !filterBridge || v.bridge === filterBridge); if (countBadge) countBadge.innerText = `${vlans.length} Entries`; if (vlans.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); vlans.forEach((v, idx) => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${v.disabled ? 'opacity-50' : ''}`; const taggedHtml = (v.tagged && v.tagged.length > 0) ? v.tagged.map(p => `${p}`).join(' ') : 'none'; const untaggedHtml = (v.untagged && v.untagged.length > 0) ? v.untagged.map(p => `${p}`).join(' ') : 'none'; const disabledFlag = v.disabled ? 'X' : '-'; tr.innerHTML = ` ${idx + 1} ${disabledFlag} ${v.bridge} ${v.vlan_ids}
${taggedHtml}
${untaggedHtml}
${v.comment || '-'} `; tbody.appendChild(tr); }); } function openAddBridgeVLANModal() { document.getElementById('bridge-vlan-modal-title').innerText = 'Add Bridge VLAN Entry'; document.getElementById('bridge-vlan-modal-id').value = ''; populateBridgeSelect('bridge-vlan-modal-bridge'); document.getElementById('bridge-vlan-modal-vlanids').value = ''; document.getElementById('bridge-vlan-modal-comment').value = ''; document.getElementById('bridge-vlan-modal-disabled').checked = false; updateBridgeVlanPortCheckboxes(); document.getElementById('bridge-vlan-modal').classList.remove('hidden'); } function openEditBridgeVLANModal(id) { const v = (state.bridgeVLANs || []).find(x => x.id === id); if (!v) return; document.getElementById('bridge-vlan-modal-title').innerText = `Edit Bridge VLAN (${v.vlan_ids})`; document.getElementById('bridge-vlan-modal-id').value = v.id; populateBridgeSelect('bridge-vlan-modal-bridge', v.bridge); document.getElementById('bridge-vlan-modal-vlanids').value = v.vlan_ids; document.getElementById('bridge-vlan-modal-comment').value = v.comment || ''; document.getElementById('bridge-vlan-modal-disabled').checked = !!v.disabled; updateBridgeVlanPortCheckboxes(v.tagged || [], v.untagged || []); document.getElementById('bridge-vlan-modal').classList.remove('hidden'); } function closeBridgeVlanModal() { document.getElementById('bridge-vlan-modal').classList.add('hidden'); } function updateBridgeVlanPortCheckboxes(taggedSelected = [], untaggedSelected = []) { const selBridge = document.getElementById('bridge-vlan-modal-bridge')?.value || ''; const taggedBox = document.getElementById('bridge-vlan-tagged-ports'); const untaggedBox = document.getElementById('bridge-vlan-untagged-ports'); if (!taggedBox || !untaggedBox) return; // Ports belonging to the selected bridge const memberPorts = (state.bridgePorts || []).filter(p => !selBridge || p.bridge === selBridge); if (memberPorts.length === 0) { taggedBox.innerHTML = 'No member ports found in this bridge'; untaggedBox.innerHTML = 'No member ports found in this bridge'; return; } taggedBox.innerHTML = memberPorts.map(p => ` `).join(''); untaggedBox.innerHTML = memberPorts.map(p => ` `).join(''); } async function submitSaveBridgeVLAN(e) { e.preventDefault(); const id = document.getElementById('bridge-vlan-modal-id').value; const bridge = document.getElementById('bridge-vlan-modal-bridge').value; const vlanIDs = document.getElementById('bridge-vlan-modal-vlanids').value.trim(); const comment = document.getElementById('bridge-vlan-modal-comment').value.trim(); const disabled = document.getElementById('bridge-vlan-modal-disabled').checked; const tagged = Array.from(document.querySelectorAll('input[name="bvlan-tagged"]:checked')).map(x => x.value); const untagged = Array.from(document.querySelectorAll('input[name="bvlan-untagged"]:checked')).map(x => x.value); try { const res = await fetch(`${API_BASE}/bridge/vlans`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, bridge, vlan_ids: vlanIDs, tagged, untagged, comment, disabled }) }); if (res.ok) { showToast('VLAN Entry Saved', `VLAN ${vlanIDs} configured on ${bridge}`, 'success'); closeBridgeVlanModal(); loadBridge(); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to save VLAN entry', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleBridgeVLANState(id, disabled) { try { const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) loadBridge(); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteBridgeVLAN(id) { if (!confirm('Delete this bridge VLAN entry?')) return; try { const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'VLAN entry removed from bridge', 'success'); loadBridge(); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Hosts / MAC Table ----------------- async function loadBridgeHosts() { const filterBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || ''; populateBridgeSelect('bridge-hosts-filter-bridge', filterBridge, true); try { const url = filterBridge ? `${API_BASE}/bridge/hosts?bridge=${encodeURIComponent(filterBridge)}` : `${API_BASE}/bridge/hosts`; const res = await fetch(url); if (res.ok) { state.bridgeHosts = await res.json(); renderBridgeHosts(); } } catch (err) { console.error('Failed to load bridge hosts:', err); } } function renderBridgeHosts() { const tbody = document.getElementById('bridge-hosts-table-body'); const empty = document.getElementById('bridge-empty-hosts'); const countBadge = document.getElementById('bridge-hosts-count-badge'); const search = (document.getElementById('bridge-hosts-search')?.value || '').toLowerCase().trim(); if (!tbody) return; tbody.innerHTML = ''; const hosts = (state.bridgeHosts || []).filter(h => { if (!search) return true; return (h.mac || '').toLowerCase().includes(search) || (h.interface || '').toLowerCase().includes(search) || (h.bridge || '').toLowerCase().includes(search); }); if (countBadge) countBadge.innerText = `${hosts.length} Hosts`; if (hosts.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); hosts.forEach(h => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; let flagBadges = ''; if (h.local) flagBadges += 'L'; if (h.static) flagBadges += 'S'; if (!h.local && !h.static) flagBadges += 'D'; tr.innerHTML = ` ${h.mac} ${h.interface || '-'} ${h.bridge || '-'} ${h.vlan_id || 1} ${h.age ? h.age.toFixed(1) + 's' : '-'} ${flagBadges} `; tbody.appendChild(tr); }); } async function flushBridgeHosts() { const selBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || (state.bridges[0] ? state.bridges[0].name : ''); if (!selBridge) { showToast('Notice', 'Please select a bridge to flush dynamic MACs', 'info'); return; } if (!confirm(`Flush all dynamic learned MAC addresses on bridge '${selBridge}'?`)) return; try { const res = await fetch(`${API_BASE}/bridge/hosts/flush`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bridge: selBridge }) }); if (res.ok) { showToast('FDB Flushed', `Dynamic MACs flushed on ${selBridge}`, 'success'); loadBridgeHosts(); } } catch (err) { showToast('Error', err.message, 'error'); } } // ----------------- Helpers ----------------- function populateBridgeSelect(selectId, currentVal = '', includeAll = false) { const sel = document.getElementById(selectId); if (!sel) return; const prevVal = currentVal || sel.value; sel.innerHTML = includeAll ? '' : ''; (state.bridges || []).forEach(b => { const opt = document.createElement('option'); opt.value = b.name; opt.innerText = `${b.name} (${b.ports_count} ports)`; if (b.name === prevVal) opt.selected = true; sel.appendChild(opt); }); } function populateBridgePortCandidateSelect(selectId, currentVal = '') { const sel = document.getElementById(selectId); if (!sel) return; sel.innerHTML = ''; // 1. Physical Interfaces const grpPhys = document.createElement('optgroup'); grpPhys.label = 'Physical Interfaces'; (state.interfaces || []).filter(i => i.type !== 'bridge' && i.type !== 'vlan' && i.type !== 'loopback').forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || i.type})`; if (i.name === currentVal) opt.selected = true; grpPhys.appendChild(opt); }); if (grpPhys.children.length > 0) sel.appendChild(grpPhys); // 2. VLAN Sub-interfaces const grpVlans = document.createElement('optgroup'); grpVlans.label = 'VLAN Sub-interfaces (802.1Q)'; (state.vlans || []).forEach(v => { const opt = document.createElement('option'); opt.value = v.name; opt.innerText = `${v.name} (VLAN ${v.vlan_id})`; if (v.name === currentVal) opt.selected = true; grpVlans.appendChild(opt); }); if (grpVlans.children.length > 0) sel.appendChild(grpVlans); // 3. EoIP Tunnels (MikroTik EoIP bridging!) const grpEoip = document.createElement('optgroup'); grpEoip.label = 'EoIP Tunnels (Layer 2 VPN)'; (state.eoipTunnels || []).forEach(t => { const opt = document.createElement('option'); opt.value = t.name; opt.innerText = `${t.name} (Tunnel ID: ${t.tunnel_id} -> ${t.remote_address})`; if (t.name === currentVal) opt.selected = true; grpEoip.appendChild(opt); }); if (grpEoip.children.length > 0) sel.appendChild(grpEoip); } // ------------------------------------------------------------- // Routing Filters Management (/routing filter) // ------------------------------------------------------------- function renderRoutingFilters() { const tbody = document.getElementById('routing-filters-table-body'); if (!tbody) return; const empty = document.getElementById('routing-filters-empty'); const countBadge = document.getElementById('routing-filters-count-badge'); const navBadge = document.getElementById('badge-filters-count'); const chainSelect = document.getElementById('routing-filter-chain-select'); const allRules = state.routingFilters || []; if (navBadge) navBadge.innerText = allRules.length; // Populate chain filter select options dynamically if (chainSelect) { const currentVal = state.filterChainSelected || 'all'; const uniqueChains = Array.from(new Set(allRules.map(r => r.chain).filter(Boolean))).sort(); chainSelect.innerHTML = `` + uniqueChains.map(ch => { const count = allRules.filter(r => r.chain === ch).length; return ``; }).join(''); chainSelect.value = currentVal; } // Filter rules by selected chain const filteredRules = (state.filterChainSelected && state.filterChainSelected !== 'all') ? allRules.filter(r => r.chain === state.filterChainSelected) : allRules.slice(); // Sort by chain then rule_order filteredRules.sort((a, b) => { if (a.chain !== b.chain) return (a.chain || '').localeCompare(b.chain || ''); return (a.rule_order || 0) - (b.rule_order || 0); }); if (countBadge) countBadge.innerText = `${filteredRules.length} Rules`; if (filteredRules.length === 0) { tbody.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; } if (empty) empty.classList.add('hidden'); tbody.innerHTML = filteredRules.map(r => { const flagA = !r.disabled ? 'A' : ''; const flagX = r.disabled ? 'X' : ''; let actionBadge = ''; if (r.action === 'accept') { actionBadge = 'accept'; } else if (r.action === 'discard') { actionBadge = 'discard'; } else if (r.action === 'reject') { actionBadge = 'reject'; } else { actionBadge = `${escapeHtml(r.action || 'accept')}`; } // Prefix match let matchPfx = ''; if (r.prefix) { matchPfx = `${escapeHtml(r.prefix)}`; if (r.prefix_len) { matchPfx += ` [${escapeHtml(r.prefix_len)}]`; } } else { matchPfx = 'any prefix'; } // Other matches const matchExtra = []; if (r.match_protocol && r.match_protocol !== 'any') { matchExtra.push(`proto:${escapeHtml(r.match_protocol)}`); } if (r.match_as_path) { matchExtra.push(`as-path:${escapeHtml(r.match_as_path)}`); } if (r.match_community) { matchExtra.push(`comm:${escapeHtml(r.match_community)}`); } const matchExtraHtml = matchExtra.length > 0 ? matchExtra.join(' ') : '-'; // Set modifications const setBadges = []; if (r.set_local_pref > 0) { setBadges.push(`loc-pref: ${r.set_local_pref}`); } if (r.set_metric > 0) { setBadges.push(`med: ${r.set_metric}`); } if (r.set_as_prepend > 0) { setBadges.push(`prepend: +${r.set_as_prepend}`); } if (r.set_community) { setBadges.push(`comm: ${escapeHtml(r.set_community)}`); } if (r.set_next_hop) { setBadges.push(`nh: ${escapeHtml(r.set_next_hop)}`); } const setHtml = setBadges.length > 0 ? setBadges.join(' ') : '-'; return ` ${flagA}${flagX} ${r.rule_order || 10} ${escapeHtml(r.chain)} ${actionBadge} ${matchPfx} ${matchExtraHtml} ${setHtml} ${escapeHtml(r.comment || '')} `; }).join(''); } function filterRoutingFiltersByChain(chain) { state.filterChainSelected = chain; renderRoutingFilters(); } function openAddRoutingFilterModal(defaultChain = '') { const form = document.getElementById('routing-filter-form'); if (form) form.reset(); const idEl = document.getElementById('modal-rf-id'); if (idEl) idEl.value = ''; const titleEl = document.getElementById('modal-rf-title'); if (titleEl) titleEl.innerText = 'Add Routing Filter Rule'; const chainEl = document.getElementById('modal-rf-chain'); if (chainEl) chainEl.value = defaultChain || (state.filterChainSelected && state.filterChainSelected !== 'all' ? state.filterChainSelected : 'bgp-in'); const orderEl = document.getElementById('modal-rf-order'); if (orderEl) orderEl.value = ((state.routingFilters || []).length + 1) * 10; const actEl = document.getElementById('modal-rf-action'); if (actEl) actEl.value = 'accept'; const protoEl = document.getElementById('modal-rf-protocol'); if (protoEl) protoEl.value = 'any'; const disEl = document.getElementById('modal-rf-disabled'); if (disEl) disEl.checked = false; const modal = document.getElementById('modal-routing-filter'); if (modal) modal.classList.remove('hidden'); } function openEditRoutingFilterModal(id) { const r = (state.routingFilters || []).find(item => item.id === id); if (!r) return; const idEl = document.getElementById('modal-rf-id'); if (idEl) idEl.value = r.id; const titleEl = document.getElementById('modal-rf-title'); if (titleEl) titleEl.innerText = 'Edit Routing Filter Rule'; const chainEl = document.getElementById('modal-rf-chain'); if (chainEl) chainEl.value = r.chain || ''; const orderEl = document.getElementById('modal-rf-order'); if (orderEl) orderEl.value = r.rule_order || 10; const actEl = document.getElementById('modal-rf-action'); if (actEl) actEl.value = r.action || 'accept'; const pfxEl = document.getElementById('modal-rf-prefix'); if (pfxEl) pfxEl.value = r.prefix || ''; const pfxLenEl = document.getElementById('modal-rf-prefix-len'); if (pfxLenEl) pfxLenEl.value = r.prefix_len || ''; const protoEl = document.getElementById('modal-rf-protocol'); if (protoEl) protoEl.value = r.match_protocol || 'any'; const aspEl = document.getElementById('modal-rf-as-path'); if (aspEl) aspEl.value = r.match_as_path || ''; const commEl = document.getElementById('modal-rf-community'); if (commEl) commEl.value = r.match_community || ''; const lpEl = document.getElementById('modal-rf-set-local-pref'); if (lpEl) lpEl.value = r.set_local_pref || ''; const metEl = document.getElementById('modal-rf-set-metric'); if (metEl) metEl.value = r.set_metric || ''; const prepEl = document.getElementById('modal-rf-set-as-prepend'); if (prepEl) prepEl.value = r.set_as_prepend || ''; const setCommEl = document.getElementById('modal-rf-set-community'); if (setCommEl) setCommEl.value = r.set_community || ''; const nhEl = document.getElementById('modal-rf-set-next-hop'); if (nhEl) nhEl.value = r.set_next_hop || ''; const comEl = document.getElementById('modal-rf-comment'); if (comEl) comEl.value = r.comment || ''; const disEl = document.getElementById('modal-rf-disabled'); if (disEl) disEl.checked = !!r.disabled; const modal = document.getElementById('modal-routing-filter'); if (modal) modal.classList.remove('hidden'); } function closeRoutingFilterModal() { const modal = document.getElementById('modal-routing-filter'); if (modal) modal.classList.add('hidden'); } async function submitRoutingFilter(e) { e.preventDefault(); const payload = { id: document.getElementById('modal-rf-id').value, chain: document.getElementById('modal-rf-chain').value.trim(), rule_order: parseInt(document.getElementById('modal-rf-order').value, 10) || 10, action: document.getElementById('modal-rf-action').value, prefix: document.getElementById('modal-rf-prefix').value.trim(), prefix_len: document.getElementById('modal-rf-prefix-len').value.trim(), match_protocol: document.getElementById('modal-rf-protocol').value, match_as_path: document.getElementById('modal-rf-as-path').value.trim(), match_community: document.getElementById('modal-rf-community').value.trim(), set_local_pref: parseInt(document.getElementById('modal-rf-set-local-pref').value, 10) || 0, set_metric: parseInt(document.getElementById('modal-rf-set-metric').value, 10) || 0, set_as_prepend: parseInt(document.getElementById('modal-rf-set-as-prepend').value, 10) || 0, set_community: document.getElementById('modal-rf-set-community').value.trim(), set_next_hop: document.getElementById('modal-rf-set-next-hop').value.trim(), comment: document.getElementById('modal-rf-comment').value.trim(), disabled: document.getElementById('modal-rf-disabled').checked }; try { const res = await fetch(`${API_BASE}/routing/filters`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeRoutingFilterModal(); showToast('Success', 'Routing filter rule saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save filter rule', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteRoutingFilter(id) { if (!confirm('Are you sure you want to delete this routing filter rule?')) return; try { const res = await fetch(`${API_BASE}/routing/filters/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Routing filter rule removed', 'info'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to delete rule', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleRoutingFilterState(id, disabled) { try { const res = await fetch(`${API_BASE}/routing/filters/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast('Status Updated', `Rule ${disabled ? 'disabled' : 'enabled'}`, 'info'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to toggle rule state', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ========================================== // TROUBLESHOOT & DIAGNOSTIC TOOLS (/tool) // ========================================== function loadToolsView() { populateToolsInterfaces(); switchToolsSubTab(state.activeToolsSubTab || 'ping'); } function populateToolsInterfaces() { const pingSel = document.getElementById('tools-ping-iface'); const traceSel = document.getElementById('tools-tr-iface'); let opts = ''; if (Array.isArray(state.interfaces)) { state.interfaces.forEach(iface => { if (iface.name) { opts += ``; } }); } if (pingSel) { const cur = pingSel.value; pingSel.innerHTML = opts; if (cur) pingSel.value = cur; } if (traceSel) { const cur = traceSel.value; traceSel.innerHTML = opts; if (cur) traceSel.value = cur; } } function switchToolsSubTab(subTab) { state.activeToolsSubTab = subTab; const tabs = ['ping', 'traceroute', 'mtr', 'dns', 'port']; tabs.forEach(t => { const btn = document.getElementById(`tools-subtab-btn-${t}`); const view = document.getElementById(`tools-view-${t}`); if (btn) { if (t === subTab) { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-amber-400 text-amber-300 transition flex items-center gap-2 whitespace-nowrap'; } else { btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap'; } } if (view) { if (t === subTab) { view.classList.remove('hidden'); } else { view.classList.add('hidden'); } } }); } function setPortPreset(port) { const input = document.getElementById('tools-port-num'); if (input) input.value = port; } let pingAbortController = null; let tracerouteAbortController = null; function clearPingTable() { const tbody = document.getElementById('tools-ping-tbody'); if (tbody) tbody.innerHTML = ''; const rawEl = document.getElementById('tools-ping-raw'); if (rawEl) rawEl.innerText = ''; const sentEl = document.getElementById('tools-ping-stat-sent'); if (sentEl) sentEl.innerText = '0'; const recvEl = document.getElementById('tools-ping-stat-recv'); if (recvEl) recvEl.innerText = '0'; const lossEl = document.getElementById('tools-ping-stat-loss'); if (lossEl) lossEl.innerText = '0%'; const avgEl = document.getElementById('tools-ping-stat-avg'); if (avgEl) avgEl.innerText = '0 ms'; const minMaxEl = document.getElementById('tools-ping-stat-minmax'); if (minMaxEl) minMaxEl.innerText = '-'; const badge = document.getElementById('tools-ping-status-badge'); if (badge) { badge.className = ''; badge.innerText = ''; } } function clearTracerouteTable() { const tbody = document.getElementById('tools-tr-tbody'); if (tbody) tbody.innerHTML = ''; const rawEl = document.getElementById('tools-tr-raw'); if (rawEl) rawEl.innerText = ''; const statHops = document.getElementById('tools-tr-hops-count'); if (statHops) statHops.innerText = '0 Hops'; } function resetPingButton() { const btn = document.getElementById('tools-ping-btn'); const btnText = document.getElementById('tools-ping-btn-text'); const spinner = document.getElementById('tools-ping-spinner'); if (btn) { btn.disabled = false; btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2'; } if (btnText) btnText.innerText = 'Start Ping'; if (spinner) spinner.classList.add('hidden'); } function setPingRunningButton() { const btn = document.getElementById('tools-ping-btn'); const btnText = document.getElementById('tools-ping-btn-text'); const spinner = document.getElementById('tools-ping-spinner'); if (btn) { btn.disabled = false; btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2'; } if (btnText) btnText.innerText = 'Stop Ping'; if (spinner) spinner.classList.remove('hidden'); } function resetTracerouteButton() { const btn = document.getElementById('tools-tr-btn'); const btnText = document.getElementById('tools-tr-btn-text'); const spinner = document.getElementById('tools-tr-spinner'); if (btn) { btn.disabled = false; btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2'; } if (btnText) btnText.innerText = 'Start Traceroute'; if (spinner) spinner.classList.add('hidden'); } function setTracerouteRunningButton() { const btn = document.getElementById('tools-tr-btn'); const btnText = document.getElementById('tools-tr-btn-text'); const spinner = document.getElementById('tools-tr-spinner'); if (btn) { btn.disabled = false; btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2'; } if (btnText) btnText.innerText = 'Stop Traceroute'; if (spinner) spinner.classList.remove('hidden'); } async function executePing(e) { if (e) e.preventDefault(); // If already running, clicking the button stops the stream! if (pingAbortController) { pingAbortController.abort(); pingAbortController = null; resetPingButton(); showToast('Ping Stopped', 'Live ping stopped by user', 'info'); return; } const target = document.getElementById('tools-ping-target')?.value?.trim(); const count = parseInt(document.getElementById('tools-ping-count')?.value || '4', 10); const size = parseInt(document.getElementById('tools-ping-size')?.value || '56', 10); const timeout = parseInt(document.getElementById('tools-ping-timeout')?.value || '3', 10); const iface = document.getElementById('tools-ping-iface')?.value || ''; const dontFrag = document.getElementById('tools-ping-df')?.checked || false; if (!target) { showToast('Input Required', 'Please enter a target host or IP', 'warning'); return; } clearPingTable(); const resCard = document.getElementById('tools-ping-results'); if (resCard) resCard.classList.remove('hidden'); pingAbortController = new AbortController(); setPingRunningButton(); try { const res = await fetch(`${API_BASE}/tools/ping/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target, count, size, timeout, interface: iface, dont_frag: dontFrag }), signal: pingAbortController.signal }); if (!res.ok) { const errText = await res.text(); showToast('Ping Error', errText || 'Failed to start live ping', 'error'); resetPingButton(); pingAbortController = null; return; } const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop() || ''; for (const block of blocks) { const trimmed = block.trim(); if (!trimmed) continue; const line = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed; try { const evt = JSON.parse(line); handleLivePingEvent(evt); } catch (parseErr) { console.warn('Ping SSE parse error:', parseErr, line); } } } } catch (err) { if (err.name === 'AbortError') { // User aborted normally } else { showToast('Ping Error', err.message, 'error'); } } finally { pingAbortController = null; resetPingButton(); } } function handleLivePingEvent(evt) { const sentEl = document.getElementById('tools-ping-stat-sent'); const recvEl = document.getElementById('tools-ping-stat-recv'); const lossEl = document.getElementById('tools-ping-stat-loss'); const avgEl = document.getElementById('tools-ping-stat-avg'); const minMaxEl = document.getElementById('tools-ping-stat-minmax'); const badge = document.getElementById('tools-ping-status-badge'); const tbody = document.getElementById('tools-ping-tbody'); const rawEl = document.getElementById('tools-ping-raw'); if (evt.raw && rawEl) { rawEl.innerText += (rawEl.innerText ? '\n' : '') + evt.raw; rawEl.scrollTop = rawEl.scrollHeight; } if (evt.type === 'reply') { if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent; if (recvEl && evt.packets_recv !== undefined) recvEl.innerText = evt.packets_recv; if (lossEl && evt.packet_loss_pct !== undefined) { lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`; lossEl.className = evt.packet_loss_pct === 0 ? 'text-base font-bold font-mono text-emerald-400 mt-1' : (evt.packet_loss_pct < 50 ? 'text-base font-bold font-mono text-amber-400 mt-1' : 'text-base font-bold font-mono text-rose-400 mt-1'); } if (avgEl && evt.avg_rtt_ms !== undefined) avgEl.innerText = `${evt.avg_rtt_ms.toFixed(2)} ms`; if (minMaxEl && evt.min_rtt_ms !== undefined && evt.max_rtt_ms !== undefined) { minMaxEl.innerText = `${evt.min_rtt_ms.toFixed(1)} / ${evt.max_rtt_ms.toFixed(1)} ms`; } if (badge) { if (evt.packet_loss_pct === 0) { badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-emerald-950 text-emerald-300 border border-emerald-800/60'; badge.innerText = '100% OK'; } else { badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-amber-950 text-amber-300 border border-amber-800/60'; badge.innerText = `${evt.packet_loss_pct.toFixed(0)}% LOSS`; } } if (tbody) { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs'; tr.innerHTML = ` #${evt.seq} ${escapeHtml(evt.from)} ${evt.bytes} bytes ${evt.ttl} ${evt.time_ms.toFixed(2)} ms REPLY `; tbody.appendChild(tr); } } else if (evt.type === 'timeout') { if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent; if (lossEl && evt.packet_loss_pct !== undefined) { lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`; lossEl.className = evt.packet_loss_pct < 50 ? 'text-base font-bold font-mono text-amber-400 mt-1' : 'text-base font-bold font-mono text-rose-400 mt-1'; } if (badge) { badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-rose-950 text-rose-300 border border-rose-800/60'; badge.innerText = `${evt.packet_loss_pct?.toFixed(0) || 100}% LOSS`; } if (tbody) { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs opacity-75'; tr.innerHTML = ` #${evt.seq} Request timed out -- -- * * * TIMEOUT `; tbody.appendChild(tr); } } else if (evt.type === 'unreach') { if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent; if (lossEl && evt.packet_loss_pct !== undefined) { lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`; lossEl.className = 'text-base font-bold font-mono text-rose-400 mt-1'; } if (badge) { badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-rose-950 text-rose-300 border border-rose-800/60'; badge.innerText = 'UNREACHABLE'; } if (tbody) { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs'; tr.innerHTML = ` #${evt.seq} ${escapeHtml(evt.from || 'Gateway')} -- -- ${escapeHtml(evt.message || 'Unreachable')} UNREACH `; tbody.appendChild(tr); } } else if (evt.type === 'stats') { if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent; if (recvEl && evt.packets_recv !== undefined) recvEl.innerText = evt.packets_recv; if (lossEl && evt.packet_loss_pct !== undefined) lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`; if (avgEl && evt.avg_rtt_ms !== undefined) avgEl.innerText = `${evt.avg_rtt_ms.toFixed(2)} ms`; if (minMaxEl && evt.min_rtt_ms !== undefined && evt.max_rtt_ms !== undefined) { minMaxEl.innerText = `${evt.min_rtt_ms.toFixed(1)} / ${evt.max_rtt_ms.toFixed(1)} ms`; } } else if (evt.type === 'done') { showToast('Ping Finished', `Sent ${evt.packets_sent}, received ${evt.packets_recv} (${evt.packet_loss_pct?.toFixed(0)}% loss)`, evt.packet_loss_pct === 0 ? 'success' : 'info'); } } async function executeTraceroute(e) { if (e) e.preventDefault(); if (tracerouteAbortController) { tracerouteAbortController.abort(); tracerouteAbortController = null; resetTracerouteButton(); showToast('Traceroute Stopped', 'Traceroute stopped by user', 'info'); return; } const target = document.getElementById('tools-tr-target')?.value?.trim(); const maxHops = parseInt(document.getElementById('tools-tr-maxhops')?.value || '15', 10); const protocol = document.getElementById('tools-tr-proto')?.value || 'icmp'; const iface = document.getElementById('tools-tr-iface')?.value || ''; const resolve = document.getElementById('tools-tr-resolve')?.checked || false; if (!target) { showToast('Input Required', 'Please enter a target host or IP', 'warning'); return; } clearTracerouteTable(); const resCard = document.getElementById('tools-tr-results'); if (resCard) resCard.classList.remove('hidden'); tracerouteAbortController = new AbortController(); setTracerouteRunningButton(); try { const res = await fetch(`${API_BASE}/tools/traceroute/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target, max_hops: maxHops, protocol, interface: iface, resolve }), signal: tracerouteAbortController.signal }); if (!res.ok) { const errText = await res.text(); showToast('Traceroute Error', errText || 'Failed to start traceroute', 'error'); resetTracerouteButton(); tracerouteAbortController = null; return; } const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop() || ''; for (const block of blocks) { const trimmed = block.trim(); if (!trimmed) continue; const line = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed; try { const evt = JSON.parse(line); handleLiveTracerouteEvent(evt); } catch (parseErr) { console.warn('Traceroute SSE parse error:', parseErr, line); } } } } catch (err) { if (err.name === 'AbortError') { // User aborted normally } else { showToast('Traceroute Error', err.message, 'error'); } } finally { tracerouteAbortController = null; resetTracerouteButton(); } } function handleLiveTracerouteEvent(evt) { const statHops = document.getElementById('tools-tr-hops-count'); const tbody = document.getElementById('tools-tr-tbody'); const rawEl = document.getElementById('tools-tr-raw'); if (evt.raw && rawEl) { rawEl.innerText += (rawEl.innerText ? '\n' : '') + evt.raw; rawEl.scrollTop = rawEl.scrollHeight; } if (evt.hops_count !== undefined && statHops) { statHops.innerText = `${evt.hops_count} Hops`; } if (evt.type === 'hop' && evt.hop && tbody) { const h = evt.hop; const isTimeout = h.status === 'timeout' || (!h.ip && !h.host); const rtt1 = Array.isArray(h.times_ms) && h.times_ms[0] ? `${h.times_ms[0]} ms` : (isTimeout ? '*' : '--'); const rtt2 = Array.isArray(h.times_ms) && h.times_ms[1] ? `${h.times_ms[1]} ms` : (isTimeout ? '*' : '--'); const displayHost = h.host && h.host !== h.ip ? `${escapeHtml(h.host)} (${escapeHtml(h.ip || '')})` : escapeHtml(h.ip || (isTimeout ? 'Request timed out' : '--')); const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs ${isTimeout ? 'opacity-60' : ''}`; tr.innerHTML = ` ${h.hop} ${displayHost} ${escapeHtml(rtt1)} ${escapeHtml(rtt2)} ${isTimeout ? 'TIMEOUT' : 'OK'} `; tbody.appendChild(tr); } else if (evt.type === 'done') { showToast('Traceroute Finished', `Traced ${evt.hops_count || 0} hops`, 'info'); } } async function executeMTR(e) { if (e) e.preventDefault(); const target = document.getElementById('tools-mtr-target')?.value?.trim(); const cycles = parseInt(document.getElementById('tools-mtr-count')?.value || '3', 10); if (!target) { showToast('Input Required', 'Please enter a target host or IP', 'warning'); return; } const btn = document.getElementById('tools-mtr-btn'); const spinner = document.getElementById('tools-mtr-spinner'); if (btn) btn.disabled = true; if (spinner) spinner.classList.remove('hidden'); try { const res = await fetch(`${API_BASE}/tools/mtr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target, count: cycles }) }); const data = await res.json(); if (!res.ok) { showToast('MTR Error', data.error || 'Failed to execute MTR', 'error'); return; } const resCard = document.getElementById('tools-mtr-results'); if (resCard) resCard.classList.remove('hidden'); const hopsCount = Array.isArray(data.hops) ? data.hops.length : 0; const statHops = document.getElementById('tools-mtr-hops-count'); if (statHops) statHops.innerText = `${hopsCount} Hops`; const tbody = document.getElementById('tools-mtr-tbody'); if (tbody) { if (hopsCount > 0) { tbody.innerHTML = data.hops.map(h => { const lossClass = h.loss_pct === 0 ? 'text-emerald-400 font-bold' : (h.loss_pct < 20 ? 'text-amber-400 font-bold' : 'text-rose-400 font-bold'); return ` ${h.hop} ${escapeHtml(h.host || '--')} ${h.loss_pct.toFixed(1)}% ${h.sent} ${h.last_ms.toFixed(1)} ${h.avg_ms.toFixed(1)} ${h.best_ms.toFixed(1)} ${h.wrst_ms.toFixed(1)} ${h.stdev_ms.toFixed(1)} `; }).join(''); } else { tbody.innerHTML = `No MTR data collected`; } } const rawEl = document.getElementById('tools-mtr-raw'); if (rawEl) rawEl.innerText = data.raw_output || 'No output'; showToast('MTR Finished', `Report completed (${hopsCount} hops discovered)`, 'info'); } catch (err) { showToast('MTR Error', err.message, 'error'); } finally { if (btn) btn.disabled = false; if (spinner) spinner.classList.add('hidden'); } } async function executeDNS(e) { if (e) e.preventDefault(); const domain = document.getElementById('tools-dns-domain')?.value?.trim(); const type = document.getElementById('tools-dns-type')?.value || 'A'; const server = document.getElementById('tools-dns-server')?.value?.trim() || ''; if (!domain) { showToast('Input Required', 'Please enter a hostname or domain', 'warning'); return; } const btn = document.getElementById('tools-dns-btn'); const spinner = document.getElementById('tools-dns-spinner'); if (btn) btn.disabled = true; if (spinner) spinner.classList.remove('hidden'); try { const res = await fetch(`${API_BASE}/tools/dns`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, type, server }) }); const data = await res.json(); if (!res.ok) { showToast('DNS Error', data.error || 'Failed to resolve DNS query', 'error'); return; } const resCard = document.getElementById('tools-dns-results'); if (resCard) resCard.classList.remove('hidden'); const metaEl = document.getElementById('tools-dns-meta'); if (metaEl) metaEl.innerText = `Resolved in ${data.query_time_ms ?? 0} ms via ${escapeHtml(data.server || 'System DNS')}`; const tbody = document.getElementById('tools-dns-tbody'); if (tbody) { if (Array.isArray(data.records) && data.records.length > 0) { tbody.innerHTML = data.records.map(r => ` ${escapeHtml(r.name)} ${r.ttl}s ${escapeHtml(r.class || 'IN')} ${escapeHtml(r.type)} ${escapeHtml(r.value)} `).join(''); } else { tbody.innerHTML = `No answer records returned for ${escapeHtml(type)} query`; } } const rawEl = document.getElementById('tools-dns-raw'); if (rawEl) rawEl.innerText = data.raw_output || 'No output'; showToast('DNS Lookup Done', `Resolved ${domain} in ${data.query_time_ms} ms`, 'success'); } catch (err) { showToast('DNS Error', err.message, 'error'); } finally { if (btn) btn.disabled = false; if (spinner) spinner.classList.add('hidden'); } } async function executePortProbe(e) { if (e) e.preventDefault(); const target = document.getElementById('tools-port-target')?.value?.trim(); const port = parseInt(document.getElementById('tools-port-num')?.value || '80', 10); const timeout = parseInt(document.getElementById('tools-port-timeout')?.value || '3', 10); if (!target || !port) { showToast('Input Required', 'Please enter a target host and TCP port', 'warning'); return; } const btn = document.getElementById('tools-port-btn'); const spinner = document.getElementById('tools-port-spinner'); if (btn) btn.disabled = true; if (spinner) spinner.classList.remove('hidden'); try { const res = await fetch(`${API_BASE}/tools/port-probe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target, port, timeout }) }); const data = await res.json(); if (!res.ok) { showToast('Probe Error', data.error || 'Failed to probe TCP port', 'error'); return; } const resCard = document.getElementById('tools-port-results'); if (resCard) resCard.classList.remove('hidden'); const banner = document.getElementById('tools-port-banner'); const icon = document.getElementById('tools-port-icon'); const title = document.getElementById('tools-port-title'); const sub = document.getElementById('tools-port-sub'); const latency = document.getElementById('tools-port-latency'); if (data.open) { if (banner) banner.className = 'rounded-xl p-5 border bg-emerald-950/30 border-emerald-800/50 flex items-center justify-between'; if (icon) { icon.className = 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm bg-emerald-500/20 text-emerald-400 border border-emerald-500/40'; icon.innerText = '✓'; } if (title) { title.className = 'text-sm font-bold text-emerald-300'; title.innerText = `PORT ${port} OPEN / LISTENING`; } if (sub) { sub.className = 'text-xs mt-0.5 font-mono text-emerald-400/80'; sub.innerText = `TCP 3-way handshake established with ${escapeHtml(target)}:${port}${data.service ? ' (' + escapeHtml(data.service) + ')' : ''}`; } if (latency) { latency.className = 'text-base font-bold font-mono text-emerald-300'; latency.innerText = `${data.latency_ms.toFixed(2)} ms`; } } else { if (banner) banner.className = 'rounded-xl p-5 border bg-rose-950/30 border-rose-800/50 flex items-center justify-between'; if (icon) { icon.className = 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm bg-rose-500/20 text-rose-400 border border-rose-500/40'; icon.innerText = '✕'; } if (title) { title.className = 'text-sm font-bold text-rose-300'; title.innerText = `PORT ${port} CLOSED / FILTERED`; } if (sub) { sub.className = 'text-xs mt-0.5 font-mono text-rose-400/80'; sub.innerText = `Connection failed or timed out to ${escapeHtml(target)}:${port}`; } if (latency) { latency.className = 'text-base font-bold font-mono text-slate-500'; latency.innerText = '--'; } } const rawEl = document.getElementById('tools-port-raw'); if (rawEl) rawEl.innerText = data.raw_output || (data.open ? `Successfully connected to ${target}:${port} in ${data.latency_ms} ms` : `Connection failed: ${data.error || 'timed out or connection refused'}`); showToast('Probe Complete', `Port ${port} is ${data.open ? 'OPEN' : 'CLOSED'}`, data.open ? 'success' : 'warning'); } catch (err) { showToast('Probe Error', err.message, 'error'); } finally { if (btn) btn.disabled = false; if (spinner) spinner.classList.add('hidden'); } } // ========================================== // DHCP CLIENT (/ip dhcp-client) CONTROLLER // ========================================== async function loadDHCPClients(showToastMsg = false) { try { const [clientsRes, sumRes] = await Promise.all([ fetch(`${API_BASE}/dhcp/clients`), fetch(`${API_BASE}/dhcp/clients/summary`) ]); if (clientsRes.ok) { state.dhcpClients = await clientsRes.json(); } if (sumRes.ok) { state.dhcpSummary = await sumRes.json(); } renderDHCPClients(); updateDHCPSummaryCards(); const bDhcp = document.getElementById('badge-dhcp-client-count'); if (bDhcp && state.dhcpSummary) { bDhcp.innerText = state.dhcpSummary.total_clients || 0; } if (showToastMsg) { showToast('Refreshed', 'DHCP Client telemetry updated', 'info'); } } catch (err) { console.error('Failed to load DHCP clients:', err); if (showToastMsg) { showToast('Notice', 'Unable to fetch DHCP client status', 'warning'); } } } function updateDHCPSummaryCards() { const sum = state.dhcpSummary || { total_clients: (state.dhcpClients || []).length, bound_clients: (state.dhcpClients || []).filter(c => c.status === 'bound').length, searching_clients: (state.dhcpClients || []).filter(c => c.status === 'searching').length, disabled_clients: (state.dhcpClients || []).filter(c => !c.enabled).length }; const totalEl = document.getElementById('dhcp-stat-total'); const boundEl = document.getElementById('dhcp-stat-bound'); const searchEl = document.getElementById('dhcp-stat-searching'); const disEl = document.getElementById('dhcp-stat-disabled'); if (totalEl) totalEl.innerText = sum.total_clients ?? 0; if (boundEl) boundEl.innerText = sum.bound_clients ?? 0; if (searchEl) searchEl.innerText = sum.searching_clients ?? 0; if (disEl) disEl.innerText = sum.disabled_clients ?? 0; } function renderDHCPClients() { const tbody = document.getElementById('dhcp-clients-tbody'); const emptyState = document.getElementById('dhcp-clients-empty'); if (!tbody) return; const clients = Array.isArray(state.dhcpClients) ? state.dhcpClients : []; if (clients.length === 0) { tbody.innerHTML = ''; if (emptyState) emptyState.classList.remove('hidden'); return; } if (emptyState) emptyState.classList.add('hidden'); tbody.innerHTML = clients.map(c => { let statusBadge = ''; if (!c.enabled) { statusBadge = 'DISABLED'; } else if (c.status === 'bound') { statusBadge = 'BOUND'; } else if (c.status === 'searching') { statusBadge = 'SEARCHING...'; } else if (c.status === 'stopped') { statusBadge = 'STOPPED'; } else { statusBadge = `${escapeHtml((c.status || 'UNKNOWN').toUpperCase())}`; } const ipDisplay = c.ip_address ? `${escapeHtml(c.ip_address)}` : 'No lease'; const gwDisplay = c.gateway ? `${escapeHtml(c.gateway)}` : '--'; const srvDisplay = c.dhcp_server ? `${escapeHtml(c.dhcp_server)}` : '--'; const dnsDisplay = (Array.isArray(c.dns_servers) && c.dns_servers.length > 0) ? `${c.dns_servers.map(d => escapeHtml(d)).join(', ')}` : '--'; const expDisplay = c.expires_in ? `${escapeHtml(c.expires_in)}` : '--'; // Option flags const flags = []; if (c.add_default_route) flags.push('+GW'); if (c.use_peer_dns) flags.push('+DNS'); if (c.use_peer_ntp) flags.push('+NTP'); flags.push(`dist:${c.default_route_distance || 1}`); return `
${escapeHtml(c.interface)} ${c.comment ? `(${escapeHtml(c.comment)})` : ''}
${statusBadge} ${ipDisplay} ${gwDisplay} ${srvDisplay} ${dnsDisplay} ${expDisplay}
${flags.join('')}
${c.enabled ? ` ` : ` `} `; }).join(''); } function openDHCPClientModal(clientId = null) { const modal = document.getElementById('modal-dhcp-client'); if (!modal) return; const title = document.getElementById('modal-dhcp-title'); const idInput = document.getElementById('modal-dhcp-id'); const ifaceSelect = document.getElementById('modal-dhcp-iface'); const distInput = document.getElementById('modal-dhcp-distance'); const commentInput = document.getElementById('modal-dhcp-comment'); const defRouteCheck = document.getElementById('modal-dhcp-def-route'); const peerDnsCheck = document.getElementById('modal-dhcp-peer-dns'); const peerNtpCheck = document.getElementById('modal-dhcp-peer-ntp'); const enabledCheck = document.getElementById('modal-dhcp-enabled'); // Populate interfaces if (ifaceSelect) { let opts = ''; const allIfaces = state.interfaces || []; const allVlans = state.vlans || []; const usedIfaces = new Set((state.dhcpClients || []).map(c => c.interface)); // Group 1: Physical & Bridge Interfaces opts += ''; allIfaces.forEach(i => { const isConfigured = usedIfaces.has(i.name) ? ' (Configured)' : ''; opts += ``; }); opts += ''; // Group 2: VLAN Interfaces if (allVlans.length > 0) { opts += ''; allVlans.forEach(v => { const isConfigured = usedIfaces.has(v.name) ? ' (Configured)' : ''; opts += ``; }); opts += ''; } ifaceSelect.innerHTML = opts; } if (clientId) { const client = (state.dhcpClients || []).find(c => c.id === clientId); if (client) { if (title) title.innerText = `Edit DHCP Client: ${client.interface}`; if (idInput) idInput.value = client.id; if (ifaceSelect) { ifaceSelect.value = client.interface; ifaceSelect.disabled = true; // interface cannot be changed on edit } if (distInput) distInput.value = client.default_route_distance || 1; if (commentInput) commentInput.value = client.comment || ''; if (defRouteCheck) defRouteCheck.checked = client.add_default_route !== false; if (peerDnsCheck) peerDnsCheck.checked = client.use_peer_dns !== false; if (peerNtpCheck) peerNtpCheck.checked = client.use_peer_ntp !== false; if (enabledCheck) enabledCheck.checked = client.enabled !== false; } } else { if (title) title.innerText = 'Add DHCP Client'; if (idInput) idInput.value = ''; if (ifaceSelect) { ifaceSelect.value = ''; ifaceSelect.disabled = false; } if (distInput) distInput.value = 1; if (commentInput) commentInput.value = ''; if (defRouteCheck) defRouteCheck.checked = true; if (peerDnsCheck) peerDnsCheck.checked = true; if (peerNtpCheck) peerNtpCheck.checked = true; if (enabledCheck) enabledCheck.checked = true; } modal.classList.remove('hidden'); } function openDHCPClientModalForIface(ifaceName) { openDHCPClientModal(); const ifaceSelect = document.getElementById('modal-dhcp-iface'); if (ifaceSelect && ifaceName) { ifaceSelect.value = ifaceName; } } function closeDHCPClientModal() { const modal = document.getElementById('modal-dhcp-client'); if (modal) modal.classList.add('hidden'); } async function submitSaveDHCPClient(e) { if (e) e.preventDefault(); const id = document.getElementById('modal-dhcp-id')?.value; const iface = document.getElementById('modal-dhcp-iface')?.value?.trim(); const distance = parseInt(document.getElementById('modal-dhcp-distance')?.value || '1', 10); const comment = document.getElementById('modal-dhcp-comment')?.value?.trim() || ''; const defRoute = document.getElementById('modal-dhcp-def-route')?.checked ?? true; const peerDns = document.getElementById('modal-dhcp-peer-dns')?.checked ?? true; const peerNtp = document.getElementById('modal-dhcp-peer-ntp')?.checked ?? true; const enabled = document.getElementById('modal-dhcp-enabled')?.checked ?? true; if (!id && !iface) { showToast('Input Required', 'Please select an interface for the DHCP client', 'warning'); return; } try { let res; if (id) { // PUT update res = await fetch(`${API_BASE}/dhcp/clients/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, add_default_route: defRoute, default_route_distance: distance, use_peer_dns: peerDns, use_peer_ntp: peerNtp, comment }) }); } else { // POST create res = await fetch(`${API_BASE}/dhcp/clients`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, enabled, add_default_route: defRoute, default_route_distance: distance, use_peer_dns: peerDns, use_peer_ntp: peerNtp, comment }) }); } const data = await res.json(); if (!res.ok) { showToast('Error', data.error || 'Failed to save DHCP client', 'error'); return; } closeDHCPClientModal(); showToast('Success', id ? 'DHCP Client updated successfully' : `DHCP Client activated on ${iface}`, 'success'); await loadDHCPClients(); fetchData(); // refresh interfaces list with new lease/status } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleDHCPClientState(id, enabled) { try { const res = await fetch(`${API_BASE}/dhcp/clients/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) }); const data = await res.json(); if (!res.ok) { showToast('Error', data.error || 'Failed to toggle client state', 'error'); return; } showToast('Updated', `DHCP client ${enabled ? 'enabled' : 'disabled'}`, 'info'); await loadDHCPClients(); fetchData(); } catch (err) { showToast('Error', err.message, 'error'); } } async function renewDHCPClient(id) { try { showToast('Renewing...', 'Requesting DHCP lease renewal from upstream server', 'info'); const res = await fetch(`${API_BASE}/dhcp/clients/${id}/renew`, { method: 'POST' }); const data = await res.json(); if (!res.ok) { showToast('Error', data.error || 'Failed to renew lease', 'error'); return; } showToast('Lease Renewed', 'DHCP renewal signal sent successfully', 'success'); setTimeout(() => { loadDHCPClients(); fetchData(); }, 1500); } catch (err) { showToast('Error', err.message, 'error'); } } async function releaseDHCPClient(id) { try { const res = await fetch(`${API_BASE}/dhcp/clients/${id}/release`, { method: 'POST' }); const data = await res.json(); if (!res.ok) { showToast('Error', data.error || 'Failed to release lease', 'error'); return; } showToast('Lease Released', 'DHCP lease released from upstream server', 'info'); await loadDHCPClients(); fetchData(); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteDHCPClient(id, ifaceName) { if (!confirm(`Are you sure you want to remove the DHCP Client on ${ifaceName}? The interface will release its dynamic IP.`)) { return; } try { const res = await fetch(`${API_BASE}/dhcp/clients/${id}`, { method: 'DELETE' }); const data = await res.json(); if (!res.ok) { showToast('Error', data.error || 'Failed to delete DHCP client', 'error'); return; } showToast('Deleted', `DHCP client removed from ${ifaceName}`, 'info'); await loadDHCPClients(); fetchData(); } catch (err) { showToast('Error', err.message, 'error'); } }