// DebTik - Debian Linux Router Frontend Application Logic // ================= Interceptor: Automatic Auth Bearer & 401 Handling ================= const originalFetch = window.fetch.bind(window); const pendingAPIReads = new Map(); const telemetryHealth = { lastSuccess: 0, failures: new Map() }; let coreRefreshPromise = null; let lastSummaryRefresh = 0; function renderTelemetryHealth() { const el = document.getElementById('telemetry-health'); if (!el) return; const failures = [...telemetryHealth.failures.entries()]; const seconds = telemetryHealth.lastSuccess ? Math.floor((Date.now() - telemetryHealth.lastSuccess) / 1000) : null; const stale = seconds === null || seconds > 15; el.textContent = failures.length ? `Data unavailable / incomplete${seconds === null ? '' : ` · updated ${seconds}s ago`}` : stale ? 'Waiting for fresh data' : `Updated ${seconds}s ago`; el.className = `text-[10px] font-mono ${failures.length || stale ? 'text-amber-400' : 'text-emerald-400'}`; el.title = failures.length ? failures.map(([path, error]) => `${path}: ${error}`).join('\n') : telemetryHealth.lastSuccess ? `Last successful update: ${new Date(telemetryHealth.lastSuccess).toLocaleTimeString()}` : 'No successful update yet'; } function stopSessionPolling() { if (window._telemetryInterval) clearInterval(window._telemetryInterval); window._telemetryInterval = null; if (trafficEventSource) trafficEventSource.close(); trafficEventSource = null; if (trafficPollFallback) clearInterval(trafficPollFallback); trafficPollFallback = null; if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer); bwtestState.pollTimer = null; if (logsTimer) clearInterval(logsTimer); logsTimer = null; stopFwStatPolling(); stopNATStatPolling(); stopMangleStatPolling(); telemetryHealth.failures.clear(); telemetryHealth.lastSuccess = 0; lastSummaryRefresh = 0; } window.fetch = async function(resource, options = {}) { const url = new URL(resource instanceof Request ? resource.url : resource, window.location.href); const isAPI = url.origin === window.location.origin && url.pathname.startsWith('/api/'); if (!isAPI) return originalFetch(resource, options); const config = { ...options, headers: new Headers(options.headers || (resource instanceof Request ? resource.headers : undefined)) }; const token = localStorage.getItem('debtik_token'); if (token && !config.headers.has('Authorization')) config.headers.set('Authorization', `Bearer ${token}`); const method = (config.method || (resource instanceof Request ? resource.method : 'GET')).toUpperCase(); const isRead = method === 'GET' && !url.pathname.includes('/stream') && !url.pathname.startsWith('/api/auth/'); const key = `${url.href}|${JSON.stringify([...config.headers])}`; if (isRead && pendingAPIReads.has(key)) return (await pendingAPIReads.get(key)).clone(); const perform = async () => { let timeout; if (isRead && !config.signal) { const controller = new AbortController(); config.signal = controller.signal; timeout = setTimeout(() => controller.abort(), 15000); } try { const response = await originalFetch(resource, config); if (isRead) { if (response.ok) { telemetryHealth.lastSuccess = Date.now(); telemetryHealth.failures.delete(url.pathname); } else { telemetryHealth.failures.set(url.pathname, `HTTP ${response.status}; displayed values may be old`); } renderTelemetryHealth(); } if (response.status === 401 && url.pathname !== '/api/auth/login' && state.currentUser && token === localStorage.getItem('debtik_token')) { stopSessionPolling(); state.isPolling = false; state.currentUser = null; localStorage.removeItem('debtik_token'); showLoginScreen(true); } return response; } catch (err) { if (isRead) { telemetryHealth.failures.set(url.pathname, err.name === 'AbortError' ? 'Request timed out' : 'Connection failed; displayed values may be old'); renderTelemetryHealth(); } throw err; } finally { if (timeout) clearTimeout(timeout); } }; if (!isRead) return perform(); const request = perform(); pendingAPIReads.set(key, request); try { return (await request).clone(); } finally { if (pendingAPIReads.get(key) === request) pendingAPIReads.delete(key); } }; let state = { currentUser: null, users: [], 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: false, 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, dhcpServerTab: 'servers', dhcpServers: [], dhcpNetworks: [], dhcpLeases: [], dhcpOptions: [], dhcpServerSummary: 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, '''); } function formatBytes(bytes) { if (bytes === undefined || bytes === null || isNaN(bytes) || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); if (i < 0 || i >= sizes.length) return `${bytes} B`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } // Initialize on page load document.addEventListener('DOMContentLoaded', async () => { initTableSorting(); // Verify authentication before bootstrapping telemetry and views const isAuthenticated = await checkAuthStatus(); if (isAuthenticated) { const hash = window.location.hash.replace('#', '') || 'dashboard'; navigate(hash); // Full state telemetry polling every 5 seconds (smooth & flicker-free) if (!window._telemetryInterval) { window._telemetryInterval = setInterval(() => { if (state.isPolling && state.currentUser && !document.hidden) { fetchData(false); } }, 5000); } } }); let trafficEventSource = null; let trafficPollFallback = null; function initLiveTrafficStream() { if (trafficEventSource) { try { trafficEventSource.close(); } catch(e) {} trafficEventSource = null; } if (trafficPollFallback) { clearInterval(trafficPollFallback); trafficPollFallback = null; } if (window.EventSource) { try { trafficEventSource = new EventSource(`${API_BASE}/interfaces/traffic/stream`); trafficEventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); updateLiveInterfaceTraffic(data); } catch (e) { console.error('Error parsing live traffic event:', e); } }; trafficEventSource.onerror = () => { // Fallback polling if SSE disconnects if (!trafficPollFallback) { trafficPollFallback = setInterval(pollLiveTrafficSnapshot, 1000); } }; trafficEventSource.onopen = () => { if (trafficPollFallback) { clearInterval(trafficPollFallback); trafficPollFallback = null; } }; return; } catch (e) { console.warn('EventSource failed, using 1s polling fallback:', e); } } // Fallback if EventSource is unavailable trafficPollFallback = setInterval(pollLiveTrafficSnapshot, 1000); } async function pollLiveTrafficSnapshot() { try { const res = await fetch(`${API_BASE}/interfaces/traffic/rates`); if (res.ok) { const data = await res.json(); updateLiveInterfaceTraffic(data); } } catch (e) {} } function updateLiveInterfaceTraffic(data) { if (!data || !data.interfaces) return; state.latestTraffic = data; // 1. Update Top & Dashboard Total Bandwidth Indicators const rxHuman = data.total_rx_human || '0 B/s'; const txHuman = data.total_tx_human || '0 B/s'; const statRx = document.getElementById('stat-traffic-rx'); if (statRx && statRx.innerText !== rxHuman) statRx.innerText = rxHuman; const statTx = document.getElementById('stat-traffic-tx'); if (statTx && statTx.innerText !== txHuman) statTx.innerText = txHuman; // 2. Update state.interfaces rates in-memory if (Array.isArray(state.interfaces)) { state.interfaces.forEach(i => { const live = data.interfaces[i.name]; if (live) { i.rate = { rx_bytes_sec: live.rx_bytes_sec, tx_bytes_sec: live.tx_bytes_sec, rx_human: live.rx_human, tx_human: live.tx_human }; i.stats = { rx_bytes: live.rx_bytes, tx_bytes: live.tx_bytes, rx_packets: live.rx_packets, tx_packets: live.tx_packets, rx_errors: live.rx_errors, tx_errors: live.tx_errors, rx_dropped: live.rx_dropped, tx_dropped: live.tx_dropped }; } }); } // 3. Update all live UI rate spans in-place (ZERO flicker) Object.keys(data.interfaces).forEach(name => { const live = data.interfaces[name]; // Dashboard Port Matrix Card const cardRate = document.getElementById(`port-card-rate-${name}`); if (cardRate && cardRate.innerText !== live.rx_human) { cardRate.innerText = live.rx_human; } // Dashboard Quick Interfaces Table const quickRx = document.getElementById(`quick-rx-${name}`); if (quickRx) quickRx.innerText = `↓ ${live.rx_human}`; const quickTx = document.getElementById(`quick-tx-${name}`); if (quickTx) quickTx.innerText = `↑ ${live.tx_human}`; // Main Interfaces Tree Table const ifaceRx = document.getElementById(`iface-rate-rx-${name}`); if (ifaceRx) ifaceRx.innerText = `↓ ${live.rx_human}`; const ifaceTx = document.getElementById(`iface-rate-tx-${name}`); if (ifaceTx) ifaceTx.innerText = `↑ ${live.tx_human}`; // VLAN Sub-interfaces Table const vlanRx = document.getElementById(`vlan-rate-rx-${name}`); if (vlanRx) vlanRx.innerText = `↓ ${live.rx_human}`; const vlanTx = document.getElementById(`vlan-rate-tx-${name}`); if (vlanTx) vlanTx.innerText = `↑ ${live.tx_human}`; // VLAN Page Table const vlanPageRx = document.getElementById(`vlan-page-rate-rx-${name}`); if (vlanPageRx) vlanPageRx.innerText = `↓ ${live.rx_human}`; const vlanPageTx = document.getElementById(`vlan-page-rate-tx-${name}`); if (vlanPageTx) vlanPageTx.innerText = `↑ ${live.tx_human}`; }); } // Navigation Handler function navigate(tabName) { if (tabName === 'dynamic-routing' || tabName === 'routing') { navigate('bgp'); return; } if (tabName === 'vlans') { navigate('interfaces'); return; } if (tabName === 'persistence') { navigate('dashboard'); return; } stopFwStatPolling(); stopNATStatPolling(); stopMangleStatPolling(); 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' }, 'dhcp-server': { title: 'DHCP Server & Subnets', sub: 'DHCP Server instances, subnet networks (NTP, TFTP, DNS), dynamic leases & static reservations' }, 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 Dynamic Routing', sub: 'BGP multi-instance peering sessions, advertised networks, and routing table (RIB)' }, ospf: { title: 'OSPF Dynamic Routing', sub: 'OSPFv2 link-state routing, dynamic neighbor adjacencies, and multi-area topology' }, 'routing-filters': { title: 'Routing Policy Filters', sub: 'Route filter chains, prefix matching, BGP community maps & policy-based path control' }, 'dynamic-routing': { title: 'BGP Dynamic Routing', sub: 'Dynamic routing engine and peering sessions' }, 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' }, aerotun: { title: 'AeroTUN High-Speed VPN', sub: 'Layer 3 encrypted tunnel with XChaCha20-Poly1305 and adaptive LZ4 compression' }, 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' }, vrrp: { title: 'VRRP High Availability', sub: 'Virtual Router Redundancy Protocol, active-backup gateway failover & Virtual IP (VIP) routing' }, bwtest: { title: 'Bandwidth Test Benchmark', sub: 'High-speed line-rate throughput benchmark, token-bucket rate limiting, loss & jitter telemetry' }, tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' }, settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' }, users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' }, certificates: { title: 'Certificates & SSL/TLS', sub: 'Root Certificate Authorities (CA), Server/Client certificates and automated Let\'s Encrypt' }, services: { title: 'Management Services', sub: 'Enable or disable Web, SSH, and Telnet management listeners and listening ports' }, updates: { title: 'Git Updates', sub: 'Check, install and restore appliance software' }, logs: { title: 'System Activity Logs', sub: 'Real-time circular buffer log viewer and topic-based event monitoring' } }; 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 === 'interfaces') { loadRoles(); if (state.activeIfaceSubTab === 'roles') switchIfaceSubTab('roles'); else switchIfaceSubTab('tree'); } 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(); switchOspfTab(state.activeOspfTab || 'instances'); } if (tabName === 'routing-filters') loadDynamicRouting(); if (tabName === 'dynamic-routing') loadDynamicRouting(); if (tabName === 'ppp') loadPPP(); if (tabName === 'eoip') loadEoIP(); if (tabName === 'aerotun') loadAeroTUN(); 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(); if (tabName === 'dhcp-server') loadDHCPServer(); if (tabName === 'vrrp') loadVRRP(); if (tabName === 'bwtest') loadBwTestView(); if (tabName === 'settings') loadSettingsData(); if (tabName === 'users') loadUsers(); if (tabName === 'certificates') loadCertificates(); if (tabName === 'services') loadServices(); if (tabName === 'updates') loadUpdateStatus(); if (tabName === 'logs') loadLogs(); setTimeout(initTableSorting, 50); } // Fetch all core system, network and VPN telemetry function fetchData(isManual = false) { if (coreRefreshPromise) return coreRefreshPromise; coreRefreshPromise = refreshCoreData(isManual).finally(() => { coreRefreshPromise = null; }); return coreRefreshPromise; } async function refreshCoreData(isManual = false) { const spinner = document.getElementById('refresh-spinner'); if (isManual && spinner) spinner.classList.add('animate-spin'); try { const refreshSummaries = isManual || Date.now() - lastSummaryRefresh > 30000; const summaryFetch = path => (refreshSummaries || (state.activeTab === 'interfaces' && ['/vlans', '/roles'].includes(path))) ? fetch(`${API_BASE}${path}`).catch(() => ({ ok: false })) : Promise.resolve({ ok: false }); const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes, rolesRes] = await Promise.all([ fetch(`${API_BASE}/system/status`).catch(() => ({ ok: false })), fetch(`${API_BASE}/interfaces`).catch(() => ({ ok: false })), summaryFetch('/vlans'), summaryFetch('/vpn/status'), summaryFetch('/firewall/summary'), summaryFetch('/routing/dynamic/summary'), summaryFetch('/bridge/summary'), summaryFetch('/dhcp/clients/summary'), summaryFetch('/dhcp/clients'), summaryFetch('/vrrp/summary').catch(() => null), summaryFetch('/roles').catch(() => null) ]); if (refreshSummaries && [vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes, rolesRes].every(r => r && r.ok)) lastSummaryRefresh = Date.now(); if (rolesRes && rolesRes.ok) { state.roles = await rolesRes.json(); const countEl = document.getElementById('badge-roles-count'); if (countEl) countEl.innerText = (state.roles || []).length; } if (vrrpSumRes && vrrpSumRes.ok) { state.vrrpSummary = await vrrpSumRes.json(); renderVRRPSummary(); } 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') { if (state.activeIfaceSubTab === 'roles') renderRolesTable(); else renderInterfacesList(); } if (state.activeTab === 'vlans') renderVlansList(); if (state.activeTab === 'firewall') await loadFirewall(false); if (state.activeTab === 'routes') await loadRoutes(false); if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') await loadDynamicRouting(false); if (state.activeTab === 'ppp') await loadPPP(false); if (state.activeTab === 'eoip') await loadEoIP(false); if (state.activeTab === 'aerotun') await loadAeroTUN(false); if (state.activeTab === 'pppoe') await loadPPPoE(false); if (state.activeTab === 'wireguard') await loadWireGuard(false); if (state.activeTab === 'ipsec') await loadIPSec(false); if (state.activeTab === 'l2tp') await loadL2TP(false); if (state.activeTab === 'pptp') await loadPPTP(false); if (state.activeTab === 'dhcp-client') await loadDHCPClients(false); if (state.activeTab === 'dhcp-server') await loadDHCPServer(false); if (state.activeTab === 'vrrp') await loadVRRP(false); if (state.activeTab === 'settings') await loadSettingsData(false); if (state.activeTab === 'users') await loadUsers(false); if (state.activeTab === 'certificates') await loadCertificates(false); if (state.activeTab === 'services') await loadServices(false); if (state.activeTab === 'updates') await loadUpdateStatus(false); if (state.activeTab === 'bwtest') await fetchBwTestServerStatus(); } 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 bAero = document.getElementById('badge-aerotun-count'); if (bAero) bAero.innerText = v.total_aerotun || 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; } const bDhcpServer = document.getElementById('badge-dhcp-server-count'); if (bDhcpServer) { bDhcpServer.innerText = state.dhcpServerSummary ? (state.dhcpServerSummary.active_leases || state.dhcpServerSummary.total_servers || 0) : (state.dhcpServers || []).length; } } // Render Dashboard View function renderDashboard() { const matrix = document.getElementById('port-matrix'); if (!matrix) return; const ifaces = state.interfaces || []; const sig = ifaces.map(i => `${i.name}:${i.state}:${(i.ipv4 || []).join(',')}:${i.type}:${i.is_vlan}`).join(';'); if (matrix.dataset.sig === sig) { return; // Structure & link states haven't changed! Do not rebuild or flicker! } matrix.dataset.sig = sig; 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} ${getRoleBadge(i.role || 'LAN')} ${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; } // DHCP Client Support Checker: Only broadcast-capable layer-2 interfaces (Ethernet, veth, VLAN, bridge, bond) function supportsDHCPClient(iface) { if (!iface) 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 point-to-point tunnel, no broadcast/DHCP) if (type === 'wireguard' || name.startsWith('wg')) return false; // Exclude PPP / PPPoE / L2TP / PPTP tunnels if (type === 'ppp' || name.startsWith('ppp') || name.startsWith('l2tp') || name.startsWith('pptp')) 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; } // Exclude bridge member/slave ports (bridge itself handles IP / DHCP) const isBridgePort = (state.bridgePorts || []).some(bp => bp.interface === iface.name); if (isBridgePort) 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(); } // ========================================================================= // INTERFACE ROLE TAGS MANAGEMENT // ========================================================================= const ROLE_COLORS = { amber: { bg: 'bg-amber-500/20', text: 'text-amber-300', border: 'border-amber-500/30' }, blue: { bg: 'bg-blue-500/20', text: 'text-blue-300', border: 'border-blue-500/30' }, cyan: { bg: 'bg-cyan-500/20', text: 'text-cyan-300', border: 'border-cyan-500/30' }, emerald: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', border: 'border-emerald-500/30' }, purple: { bg: 'bg-purple-500/20', text: 'text-purple-300', border: 'border-purple-500/30' }, rose: { bg: 'bg-rose-500/20', text: 'text-rose-300', border: 'border-rose-500/30' }, indigo: { bg: 'bg-indigo-500/20', text: 'text-indigo-300', border: 'border-indigo-500/30' }, sky: { bg: 'bg-sky-500/20', text: 'text-sky-300', border: 'border-sky-500/30' }, slate: { bg: 'bg-slate-800', text: 'text-slate-300', border: 'border-slate-700' } }; function getRoleBadge(roleName) { const name = roleName || 'LAN'; const role = (state.roles || []).find(r => r.name.toLowerCase() === name.toLowerCase() || r.id.toLowerCase() === name.toLowerCase()); const colorKey = (role?.color || 'blue').toLowerCase(); const theme = ROLE_COLORS[colorKey] || ROLE_COLORS.blue; return `${escapeHtml(name)}`; } function switchIfaceSubTab(subTab) { state.activeIfaceSubTab = subTab; const treeTab = document.getElementById('ifaces-subtab-tree'); const rolesTab = document.getElementById('ifaces-subtab-roles'); const btnTree = document.getElementById('ifaces-subtab-btn-tree'); const btnRoles = document.getElementById('ifaces-subtab-btn-roles'); if (subTab === 'roles') { treeTab?.classList.add('hidden'); rolesTab?.classList.remove('hidden'); if (btnRoles) { btnRoles.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-brand-500/10 text-brand-400 border border-brand-500/20 transition flex items-center gap-1.5'; } if (btnTree) { btnTree.className = 'px-3.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-white hover:bg-slate-800 transition flex items-center gap-1.5'; } loadRoles(); } else { rolesTab?.classList.add('hidden'); treeTab?.classList.remove('hidden'); if (btnTree) { btnTree.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-brand-500/10 text-brand-400 border border-brand-500/20 transition flex items-center gap-1.5'; } if (btnRoles) { btnRoles.className = 'px-3.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-white hover:bg-slate-800 transition flex items-center gap-1.5'; } renderInterfacesList(); } } async function loadRoles() { try { const res = await fetch(`${API_BASE}/roles`); if (res.ok) { state.roles = await res.json(); const countEl = document.getElementById('badge-roles-count'); if (countEl) countEl.innerText = (state.roles || []).length; renderRolesTable(); populateRoleSelects(); } } catch (err) { console.error('Failed to load role tags:', err); } } function renderRolesTable() { const tbody = document.getElementById('roles-table-body'); const empty = document.getElementById('roles-empty-state'); if (!tbody) return; const roles = state.roles || []; if (roles.length === 0) { tbody.innerHTML = ''; empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); const sig = roles.map(r => `${r.id}:${r.name}:${r.color}:${r.description}:${(r.interfaces||[]).join(',')}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; tbody.innerHTML = roles.map(r => { const colorKey = (r.color || 'blue').toLowerCase(); const theme = ROLE_COLORS[colorKey] || ROLE_COLORS.blue; const ifaces = Array.isArray(r.interfaces) ? r.interfaces : []; const ifacesChips = ifaces.length > 0 ? ifaces.map(name => `${escapeHtml(name)}`).join('') : 'Unassigned (0)'; return ` ${escapeHtml(r.name)} ${escapeHtml(r.name)} ${r.builtin ? 'Builtin' : ''} ${escapeHtml(r.color || 'blue')} ${escapeHtml(r.description || '-')}
${ifacesChips}
`; }).join(''); } function openRoleModal(id = null) { const modal = document.getElementById('role-modal'); if (!modal) return; const idInput = document.getElementById('role-modal-id'); const nameInput = document.getElementById('role-modal-name'); const colorSelect = document.getElementById('role-modal-color'); const descInput = document.getElementById('role-modal-desc'); const title = document.getElementById('role-modal-title'); if (id) { const role = (state.roles || []).find(r => r.id === id); if (role) { if (title) title.innerText = `Edit Role Tag: ${role.name}`; if (idInput) idInput.value = role.id; if (nameInput) nameInput.value = role.name; if (colorSelect) colorSelect.value = role.color || 'blue'; if (descInput) descInput.value = role.description || ''; } } else { if (title) title.innerText = 'Add Role Tag'; if (idInput) idInput.value = ''; if (nameInput) nameInput.value = ''; if (colorSelect) colorSelect.value = 'blue'; if (descInput) descInput.value = ''; } updateRoleModalPreview(); modal.classList.remove('hidden'); } function closeRoleModal() { const modal = document.getElementById('role-modal'); if (modal) modal.classList.add('hidden'); } function updateRoleModalPreview() { const name = document.getElementById('role-modal-name')?.value.trim() || 'Preview'; const colorKey = (document.getElementById('role-modal-color')?.value || 'blue').toLowerCase(); const theme = ROLE_COLORS[colorKey] || ROLE_COLORS.blue; const preview = document.getElementById('role-modal-preview'); if (preview) { preview.innerText = name; preview.className = `px-2.5 py-1 rounded text-xs font-semibold ${theme.bg} ${theme.text} border ${theme.border}`; } } async function submitSaveRole(e) { e.preventDefault(); const id = document.getElementById('role-modal-id')?.value; const name = document.getElementById('role-modal-name')?.value.trim(); const color = document.getElementById('role-modal-color')?.value; const desc = document.getElementById('role-modal-desc')?.value.trim(); if (!name) return; try { const payload = { id, name, color, description: desc }; const method = id ? 'PUT' : 'POST'; const url = id ? `${API_BASE}/roles/${encodeURIComponent(id)}` : `${API_BASE}/roles`; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { showToast('Role Tag Saved', `Role tag "${name}" saved successfully`, 'success'); closeRoleModal(); await loadRoles(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to save role tag', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteRoleTag(id, name) { if (!confirm(`Are you sure you want to delete role tag "${name}"?\nInterfaces assigned to this role will default to LAN.`)) return; try { const res = await fetch(`${API_BASE}/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Role Tag Deleted', `Role tag "${name}" removed`, 'success'); await loadRoles(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to delete role tag', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function populateRoleSelects(selectedRole = '') { const ifaceSelect = document.getElementById('modal-iface-role'); const vlanSelect = document.getElementById('vlan-role'); const roles = state.roles || []; const optionsHtml = roles.map(r => ``).join(''); if (ifaceSelect) { const currentVal = selectedRole || ifaceSelect.value || 'LAN'; if (ifaceSelect.dataset.optionsSig !== optionsHtml) { ifaceSelect.innerHTML = optionsHtml; ifaceSelect.dataset.optionsSig = optionsHtml; } if (currentVal) ifaceSelect.value = currentVal; } if (vlanSelect) { const currentVal = selectedRole || vlanSelect.value || 'LAN'; if (vlanSelect.dataset.optionsSig !== optionsHtml) { vlanSelect.innerHTML = optionsHtml; vlanSelect.dataset.optionsSig = optionsHtml; } if (currentVal) vlanSelect.value = currentVal; } } // 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(); 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'); tbody.innerHTML = ''; return; } emptyState?.classList.add('hidden'); const sig = `${state.ifaceFilter}|${query}|` + filteredParents.map(p => { const cVlans = allVlans.filter(v => v.parent_name === p.name || v.name.startsWith(p.name + '.')); const dhcp = (state.dhcpClients || []).find(dc => dc.interface === p.name); return `${p.name}:${p.state}:${p.role||'LAN'}:${(p.ipv4||[]).join(',')}:${p.mtu}:${cVlans.length}:${state.expandedParents.has(p.name)}:${dhcp?.status || ''}`; }).join(';'); if (tbody.dataset.sig === sig) { return; // ZERO changes to structure, links, IPs or VLANs! Do not rebuild DOM! } tbody.dataset.sig = sig; tbody.innerHTML = ''; 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'))}
${parent.name} ${hasVlans ? ` ${childVlans.length} VLAN${childVlans.length > 1 ? 's' : ''} ` : ` 0 VLANs `}
${parent.type}
${getRoleBadge(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 ``; } if (!supportsDHCPClient(parent)) { 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()}
`; })()}
${getRoleBadge(v.role || 'LAN')} ${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 ``; } if (!supportsDHCPClient(v)) { 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(); 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'); tbody.innerHTML = ''; return; } emptyState.classList.add('hidden'); const sig = `${query}|` + filtered.map(v => `${v.name}:${v.state}:${(v.ipv4||[]).join(',')}:${v.vlan_id}:${v.mtu}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; tbody.innerHTML = ''; 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); }); } // ======================== PPP Profiles Shared Helper ======================== async function fetchPPPProfilesList() { try { const res = await fetch(`${API_BASE}/vpn/ppp/profiles`); if (res.ok) { state.pppProfiles = await res.json(); } } catch (err) { console.error('Failed to load PPP profiles:', err); } } function populateProfileSelect(selectId, selectedVal, defaultVal = 'default') { const sel = document.getElementById(selectId); if (!sel) return; const profiles = (state.pppProfiles && state.pppProfiles.length > 0) ? state.pppProfiles : [ { name: 'default', use_encryption: 'no' }, { name: 'default-encryption', use_encryption: 'required' } ]; sel.innerHTML = ''; profiles.forEach(p => { const opt = document.createElement('option'); opt.value = p.name; const encBadge = p.use_encryption === 'required' ? ' (MPPE-128)' : (p.use_encryption === 'yes' ? ' (MPPE)' : ''); opt.innerText = `${p.name}${encBadge}`; sel.appendChild(opt); }); if (selectedVal) { sel.value = selectedVal; } else { sel.value = defaultVal; } if (!sel.value && sel.options.length > 0) { sel.selectedIndex = 0; } } // ======================== PPPoE Multi-Instance ======================== async function loadPPPoE(populateInputs = true) { try { if (!state.pppProfiles || state.pppProfiles.length === 0) { await fetchPPPProfilesList(); } 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)} ${escapeHtml(inst.name)} ${escapeHtml(inst.interface)} ${escapeHtml(inst.username || '-')} ${escapeHtml(inst.profile || 'default')} ${escapeHtml(inst.assigned_ip || '-')} ${escapeHtml(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 profiles populateProfileSelect('pppoe-modal-profile', 'default', 'default'); // 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; // Populate profiles populateProfileSelect('pppoe-modal-profile', inst.profile || 'default', 'default'); 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 profile = document.getElementById('pppoe-modal-profile')?.value || 'default'; 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, profile: profile, 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 formatWGPeerEndpoint(address, port) { if (!address || address === '(none)') return '--'; let addr = address.trim(); let p = port || 0; while (addr.includes(':') && !addr.startsWith('[')) { const lastColon = addr.lastIndexOf(':'); const maybePort = addr.substring(lastColon + 1); if (!isNaN(parseInt(maybePort, 10)) && parseInt(maybePort, 10) > 0) { if (!p) p = parseInt(maybePort, 10); addr = addr.substring(0, lastColon); } else { break; } } return p ? `${addr}:${p}` : addr; } 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(', ')} ${formatWGPeerEndpoint(p.endpoint_address, 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(', '); let epHost = (peer.endpoint_address || '').trim(); let epPort = peer.endpoint_port || ''; while (epHost.includes(':') && !epHost.startsWith('[')) { const lastColon = epHost.lastIndexOf(':'); const maybePort = epHost.substring(lastColon + 1); if (!isNaN(parseInt(maybePort, 10)) && parseInt(maybePort, 10) > 0) { if (!epPort) epPort = parseInt(maybePort, 10); epHost = epHost.substring(0, lastColon); } else { break; } } document.getElementById('wg-peer-modal-endpoint').value = epHost; document.getElementById('wg-peer-modal-port').value = epPort; 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); let epHost = document.getElementById('wg-peer-modal-endpoint').value.trim(); let epPort = parseInt(document.getElementById('wg-peer-modal-port').value, 10) || 0; while (epHost.includes(':') && !epHost.startsWith('[')) { const lastColon = epHost.lastIndexOf(':'); const maybePort = epHost.substring(lastColon + 1); if (!isNaN(parseInt(maybePort, 10)) && parseInt(maybePort, 10) > 0) { if (!epPort) epPort = parseInt(maybePort, 10); epHost = epHost.substring(0, lastColon); } else { break; } } 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 FortiGate Phase 1 & 2 Multi-Tunnel Engine ======================== function switchIpsecModalTab(tab) { const p1Tab = document.getElementById('tab-btn-ipsec-p1'); const p2Tab = document.getElementById('tab-btn-ipsec-p2'); const p1Content = document.getElementById('ipsec-tab-content-p1'); const p2Content = document.getElementById('ipsec-tab-content-p2'); const stepBtn = document.getElementById('btn-ipsec-switch-step'); if (tab === 'p1') { if (p1Tab) { p1Tab.className = 'px-4 py-2 text-xs font-bold border-b-2 border-amber-500 text-amber-300 transition flex items-center gap-2'; } if (p2Tab) { p2Tab.className = 'px-4 py-2 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2'; } if (p1Content) p1Content.classList.remove('hidden'); if (p2Content) p2Content.classList.add('hidden'); if (stepBtn) stepBtn.innerHTML = 'Next: Phase 2 Selectors →'; } else { if (p1Tab) { p1Tab.className = 'px-4 py-2 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2'; } if (p2Tab) { p2Tab.className = 'px-4 py-2 text-xs font-bold border-b-2 border-cyan-500 text-cyan-300 transition flex items-center gap-2'; } if (p1Content) p1Content.classList.add('hidden'); if (p2Content) p2Content.classList.remove('hidden'); if (stepBtn) stepBtn.innerHTML = '← Back: Phase 1 (IKE)'; } } function toggleIpsecPhaseStep() { const p1Content = document.getElementById('ipsec-tab-content-p1'); if (p1Content && !p1Content.classList.contains('hidden')) { switchIpsecModalTab('p2'); } else { switchIpsecModalTab('p1'); } } function toggleIpsecPskVisibility() { const pskInput = document.getElementById('ipsec-modal-psk'); if (!pskInput) return; pskInput.type = pskInput.type === 'password' ? 'text' : 'password'; } function addIpsecLocalSubnet(val = '') { const container = document.getElementById('ipsec-modal-local-subnets-container'); if (!container) return; const row = document.createElement('div'); row.className = 'flex items-center gap-2 ipsec-subnet-row'; row.innerHTML = ` `; container.appendChild(row); } function addIpsecRemoteSubnet(val = '') { const container = document.getElementById('ipsec-modal-remote-subnets-container'); if (!container) return; const row = document.createElement('div'); row.className = 'flex items-center gap-2 ipsec-subnet-row'; row.innerHTML = ` `; container.appendChild(row); } function updateIpsecEspProposalFromPFS(val) { const prop = document.getElementById('ipsec-modal-espprop'); if (!prop) return; if (val === 'none') { prop.value = 'aes256-sha256,aes128-sha256,default'; } else { prop.value = `aes256-sha256-${val},aes128-sha256-${val},default`; } } function updateIpsecIkeProposalFromDH(dh) { const el = document.getElementById('ipsec-modal-ikeprop'); if (el) { el.value = `aes256-sha256-${dh},aes128-sha256-${dh},default`; } } function safeSetVal(id, val) { const el = document.getElementById(id); if (el) el.value = val; } function safeSetChecked(id, val) { const el = document.getElementById(id); if (el) el.checked = !!val; } function safeGetVal(id, def = '') { const el = document.getElementById(id); return el ? el.value : def; } 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 select-none'; // Local subnets tags const localList = (c.local_subnets && c.local_subnets.length > 0) ? c.local_subnets : (c.local_subnet ? [c.local_subnet] : ['0.0.0.0/0']); const localBadges = localList.map(s => `${escapeHtml(s)}`).join(''); // Remote subnets tags const remoteList = (c.remote_subnets && c.remote_subnets.length > 0) ? c.remote_subnets : (c.remote_subnet ? [c.remote_subnet] : ['0.0.0.0/0']); const remoteBadges = remoteList.map(s => `${escapeHtml(s)}`).join(''); const ikeVersionBadge = (c.ike_version || 'ikev2').toUpperCase(); const p2Name = c.phase2_name || `${c.name}_p2`; tr.innerHTML = ` ${isEstablished ? ` UP ` : (c.disabled ? ` DOWN ` : ` IDLE `)}
🛡
${escapeHtml(c.name)}
P2: ${escapeHtml(p2Name)}
${escapeHtml(c.remote_addrs || '%any')}
${ikeVersionBadge} ${c.auth_method || 'PSK'}
Local: ${localBadges}
Remote: ${remoteBadges}
${escapeHtml(c.esp_proposals || 'aes256-sha256')}
PFS: ${escapeHtml(c.pfs_group || 'modp2048')}
${isEstablished ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load IPSec:', err); } } function openAddIPSecModal() { const modal = document.getElementById('ipsec-modal'); if (!modal) return; const title = document.getElementById('ipsec-modal-title'); if (title) title.innerText = 'Configure IPSec Site-to-Site Tunnel'; // Phase 1 Fields safeSetVal('ipsec-modal-name', `tunnel-${(state.ipsecTunnels || []).length + 1}`); const nameEl = document.getElementById('ipsec-modal-name'); if (nameEl) nameEl.readOnly = false; safeSetVal('ipsec-modal-ikeversion', 'ikev2'); safeSetVal('ipsec-modal-ikever', 'ikev2'); safeSetVal('ipsec-modal-remotegw', ''); safeSetVal('ipsec-modal-localaddrs', ''); safeSetVal('ipsec-modal-comments', ''); safeSetVal('ipsec-modal-psk', ''); safeSetVal('ipsec-modal-ikeprop', 'aes256-sha256-modp2048,aes128-sha256-modp2048,default'); safeSetVal('ipsec-modal-dhgroup', 'modp2048'); safeSetVal('ipsec-modal-ikekeylife', '86400'); safeSetVal('ipsec-modal-localid', ''); safeSetVal('ipsec-modal-remoteid', ''); safeSetVal('ipsec-modal-dpd', 'on-demand'); // Phase 2 Fields safeSetVal('ipsec-modal-p2name', `tunnel-${(state.ipsecTunnels || []).length + 1}_p2`); safeSetVal('ipsec-modal-espkeylife', '28800'); safeSetVal('ipsec-modal-espprop', 'aes256-sha256-modp2048,aes128-sha256-modp2048,default'); safeSetVal('ipsec-modal-pfsgroup', 'modp2048'); safeSetChecked('ipsec-modal-autostart', true); safeSetChecked('ipsec-modal-disabled', false); // Clear & Populate default dynamic subnets const localContainer = document.getElementById('ipsec-modal-local-subnets-container'); const remoteContainer = document.getElementById('ipsec-modal-remote-subnets-container'); if (localContainer) { localContainer.innerHTML = ''; addIpsecLocalSubnet('192.168.10.0/24'); } if (remoteContainer) { remoteContainer.innerHTML = ''; addIpsecRemoteSubnet('192.168.20.0/24'); } switchIpsecModalTab('p1'); modal.classList.remove('hidden'); } function openEditIPSecModal(name) { const modal = document.getElementById('ipsec-modal'); if (!modal) return; const conn = (state.ipsecTunnels || []).find(c => c.name === name); if (!conn) return; const title = document.getElementById('ipsec-modal-title'); if (title) title.innerText = `Edit IPSec Tunnel: ${name}`; // Phase 1 Fields safeSetVal('ipsec-modal-name', conn.name); const nameEl = document.getElementById('ipsec-modal-name'); if (nameEl) nameEl.readOnly = true; safeSetVal('ipsec-modal-ikeversion', conn.ike_version || 'ikev2'); safeSetVal('ipsec-modal-ikever', conn.ike_version || 'ikev2'); safeSetVal('ipsec-modal-remotegw', conn.remote_addrs || ''); safeSetVal('ipsec-modal-localaddrs', conn.local_addrs || ''); safeSetVal('ipsec-modal-comments', conn.comment || ''); safeSetVal('ipsec-modal-psk', conn.preshared_key || ''); safeSetVal('ipsec-modal-ikeprop', conn.ike_proposals || 'aes256-sha256-modp2048,default'); safeSetVal('ipsec-modal-ikekeylife', conn.ike_keylife || 86400); safeSetVal('ipsec-modal-localid', conn.local_id || ''); safeSetVal('ipsec-modal-remoteid', conn.remote_id || ''); safeSetVal('ipsec-modal-dpd', conn.dpd || 'on-demand'); // Phase 2 Fields safeSetVal('ipsec-modal-p2name', conn.phase2_name || `${conn.name}_p2`); safeSetVal('ipsec-modal-espkeylife', conn.esp_keylife || 28800); safeSetVal('ipsec-modal-espprop', conn.esp_proposals || 'aes256-sha256-modp2048,default'); safeSetVal('ipsec-modal-pfsgroup', conn.pfs_group || 'modp2048'); safeSetChecked('ipsec-modal-autostart', conn.auto_start ?? true); safeSetChecked('ipsec-modal-disabled', !!conn.disabled); // Populate dynamic subnets const localContainer = document.getElementById('ipsec-modal-local-subnets-container'); const remoteContainer = document.getElementById('ipsec-modal-remote-subnets-container'); if (localContainer) { localContainer.innerHTML = ''; const locals = (conn.local_subnets && conn.local_subnets.length > 0) ? conn.local_subnets : (conn.local_subnet ? [conn.local_subnet] : ['192.168.10.0/24']); locals.forEach(s => addIpsecLocalSubnet(s)); } if (remoteContainer) { remoteContainer.innerHTML = ''; const remotes = (conn.remote_subnets && conn.remote_subnets.length > 0) ? conn.remote_subnets : (conn.remote_subnet ? [conn.remote_subnet] : ['192.168.20.0/24']); remotes.forEach(s => addIpsecRemoteSubnet(s)); } switchIpsecModalTab('p1'); modal.classList.remove('hidden'); } function closeAddIPSecModal() { const modal = document.getElementById('ipsec-modal'); if (modal) modal.classList.add('hidden'); } async function submitSaveIPSec(e) { if (e) e.preventDefault(); const name = safeGetVal('ipsec-modal-name').trim(); if (!name) { showToast('Error', 'Tunnel name is required', 'error'); return; } const ikeVersion = safeGetVal('ipsec-modal-ikeversion') || safeGetVal('ipsec-modal-ikever', 'ikev2'); const remotegw = safeGetVal('ipsec-modal-remotegw').trim(); const localAddrs = safeGetVal('ipsec-modal-localaddrs').trim() || '%any'; const comments = safeGetVal('ipsec-modal-comments').trim(); const psk = safeGetVal('ipsec-modal-psk'); const ikeProposals = safeGetVal('ipsec-modal-ikeprop', 'aes256-sha256-modp2048,default').trim(); const ikeKeylife = parseInt(safeGetVal('ipsec-modal-ikekeylife', '86400'), 10) || 86400; const localId = safeGetVal('ipsec-modal-localid').trim(); const remoteId = safeGetVal('ipsec-modal-remoteid').trim(); const dpd = safeGetVal('ipsec-modal-dpd', 'on-demand'); const phase2Name = safeGetVal('ipsec-modal-p2name').trim() || `${name}_p2`; const espProposals = safeGetVal('ipsec-modal-espprop', 'aes256-sha256-modp2048,default').trim(); const espKeylife = parseInt(safeGetVal('ipsec-modal-espkeylife', '28800'), 10) || 28800; const pfsGroup = safeGetVal('ipsec-modal-pfsgroup', 'modp2048'); const autostart = document.getElementById('ipsec-modal-autostart')?.checked ?? true; const disabled = document.getElementById('ipsec-modal-disabled')?.checked ?? false; // Scrape dynamic local and remote subnets const localInputs = document.querySelectorAll('#ipsec-modal-local-subnets-container input'); const localSubnets = []; localInputs.forEach(inp => { const v = inp.value.trim(); if (v) localSubnets.push(v); }); const remoteInputs = document.querySelectorAll('#ipsec-modal-remote-subnets-container input'); const remoteSubnets = []; remoteInputs.forEach(inp => { const v = inp.value.trim(); if (v) remoteSubnets.push(v); }); try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, comment: comments, ike_version: ikeVersion, local_addrs: localAddrs, remote_addrs: remotegw, preshared_key: psk, ike_proposals: ikeProposals, ike_keylife: ikeKeylife, local_id: localId, remote_id: remoteId, dpd: dpd, phase2_name: phase2Name, esp_proposals: espProposals, esp_keylife: espKeylife, pfs_group: pfsGroup, pfs: pfsGroup !== 'none', local_subnets: localSubnets, remote_subnets: remoteSubnets, local_subnet: localSubnets[0] || '0.0.0.0/0', remote_subnet: remoteSubnets[0] || '0.0.0.0/0', auto_start: autostart, disabled }) }); if (res.ok) { showToast('IPSec Saved', `FortiGate Tunnel ${name} configured`, 'success'); closeAddIPSecModal(); loadIPSec(false); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save IPSec tunnel', 'error'); } } 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 { if (!state.pppProfiles || state.pppProfiles.length === 0) { await fetchPPPProfilesList(); } 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)} ${escapeHtml(inst.name)} ${escapeHtml(inst.connect_to)} ${escapeHtml(inst.username || '-')} ${escapeHtml(inst.profile || 'default-encryption')} ${escapeHtml(inst.assigned_ip || '-')} ${escapeHtml(inst.ppp_device || '-')} ${inst.default_route ? 'Yes' : 'No'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load L2TP:', err); } } async function openAddL2TPModal() { if (!state.pppProfiles || state.pppProfiles.length === 0) { await fetchPPPProfilesList(); } 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; populateProfileSelect('l2tp-modal-profile', 'default-encryption', 'default-encryption'); document.getElementById('l2tp-modal').classList.remove('hidden'); } async function openEditL2TPModal(name) { if (!state.pppProfiles || state.pppProfiles.length === 0) { await fetchPPPProfilesList(); } 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; populateProfileSelect('l2tp-modal-profile', inst.profile || 'default-encryption', 'default-encryption'); 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 profile = document.getElementById('l2tp-modal-profile')?.value || 'default-encryption'; 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, profile: profile, 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 { if (!state.pppProfiles || state.pppProfiles.length === 0) { await fetchPPPProfilesList(); } 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)} ${escapeHtml(inst.name)} ${escapeHtml(inst.connect_to)} ${escapeHtml(inst.username || '-')} ${escapeHtml(inst.profile || 'default-encryption')} ${escapeHtml(inst.assigned_ip || '-')} ${escapeHtml(inst.ppp_device || '-')} ${inst.require_mppe ? 'MPPE-128' : 'None'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load PPTP:', err); } } async function openAddPPTPModal() { if (!state.pppProfiles || state.pppProfiles.length === 0) { await fetchPPPProfilesList(); } 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; populateProfileSelect('pptp-modal-profile', 'default-encryption', 'default-encryption'); document.getElementById('pptp-modal').classList.remove('hidden'); } async function openEditPPTPModal(name) { if (!state.pppProfiles || state.pppProfiles.length === 0) { await fetchPPPProfilesList(); } 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; populateProfileSelect('pptp-modal-profile', inst.profile || 'default-encryption', 'default-encryption'); 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 profile = document.getElementById('pptp-modal-profile')?.value || 'default-encryption'; 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, profile: profile, 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 = ''; populateRoleSelects('LAN'); 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; populateRoleSelects(iface.role || 'LAN'); document.getElementById('modal-iface-desc').value = iface.description || ''; const nameInput = document.getElementById('modal-iface-new-name'); const renameBtn = document.getElementById('modal-btn-rename'); const renameHelp = document.getElementById('modal-iface-rename-help'); if (nameInput) { nameInput.value = iface.name; if (iface.name === 'lo' || iface.type === 'loopback') { nameInput.disabled = true; if (renameBtn) renameBtn.disabled = true; if (renameHelp) renameHelp.innerText = 'Loopback interface cannot be renamed.'; } else { nameInput.disabled = false; if (renameBtn) renameBtn.disabled = false; if (renameHelp) renameHelp.innerText = 'Device name in Linux (max 15 characters). Automatically preserves operational link state and metadata.'; } } 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) { const target = name || state.currentModalIface; if (!target) return; await toggleInterfaceState(target, newState); const updated = (state.interfaces || []).find(i => i.name === target); if (updated) openIfaceModal(target); } 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 renameInterface() { const oldName = state.currentModalIface; const newNameInput = document.getElementById('modal-iface-new-name'); const newName = (newNameInput?.value || '').trim(); if (!oldName) return; if (!newName) { showToast('Warning', 'Interface name cannot be empty', 'warning'); return; } if (newName === oldName) { showToast('Info', `Interface name is already "${oldName}"`, 'info'); return; } if (newName.length > 15) { showToast('Validation Error', 'Interface name cannot exceed 15 characters (Linux IFNAMSIZ)', 'error'); return; } if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(newName)) { showToast('Validation Error', 'Name must contain only alphanumeric characters, underscores, hyphens or dots', 'error'); return; } const renameBtn = document.getElementById('modal-btn-rename'); if (renameBtn) { renameBtn.disabled = true; renameBtn.innerHTML = `⟳ Renaming...`; } try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(oldName)}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_name: newName }) }); const data = await res.json(); if (!res.ok) { showToast('Rename Failed', data.error || data.message || 'Failed to rename interface', 'error'); return; } showToast('Interface Renamed', `Successfully renamed ${oldName} → ${newName}`, 'success'); state.currentModalIface = newName; document.getElementById('modal-iface-name').innerText = newName; // Refresh application state await fetchData(false); const updated = (state.interfaces || []).find(i => i.name === newName); if (updated) { openIfaceModal(newName); } else { closeIfaceModal(); } } catch (err) { showToast('Error', err.message, 'error'); } finally { if (renameBtn) { renameBtn.disabled = false; renameBtn.innerHTML = ` Rename `; } } } async function saveInterfaceMetadata() { const oldName = state.currentModalIface; const newName = (document.getElementById('modal-iface-new-name')?.value || '').trim(); const role = document.getElementById('modal-iface-role').value; const desc = document.getElementById('modal-iface-desc').value.trim(); let targetName = oldName; // If user changed the name before clicking Save Changes, execute rename first if (newName && newName !== oldName && oldName !== 'lo') { try { const renameRes = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(oldName)}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_name: newName }) }); const data = await renameRes.json(); if (!renameRes.ok) { showToast('Rename Failed', data.error || data.message || 'Could not rename interface', 'error'); return; } targetName = newName; state.currentModalIface = newName; showToast('Renamed', `Interface renamed from ${oldName} to ${newName}`, 'success'); } catch (err) { showToast('Error', err.message, 'error'); return; } } try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(targetName)}/metadata`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, description: desc }) }); if (res.ok) { showToast('Saved', 'Interface configuration updated', 'success'); closeIfaceModal(); fetchData(false); } else { const err = await res.json(); showToast('Error', err.error || err.message || 'Failed to save metadata', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== IPTABLES FIREWALL ======================== state.fwSubTab = 'filter'; state.routesSubTab = 'routes'; function nativeNATChain(chain) { const value = (chain || '').toLowerCase(); if (value === 'dstnat') return 'prerouting'; if (value === 'srcnat') return 'postrouting'; return ['prerouting', 'input', 'output', 'postrouting'].includes(value) ? value : (chain || ''); } function nativeFirewallTarget(action) { const name = (action || '').toLowerCase(); return ({'src-nat':'SNAT', 'dst-nat':'DNAT', 'add-src-to-address-list':'SET (add source)', 'add-dst-to-address-list':'SET (add destination)', 'mark-connection':'CONNMARK', 'mark-packet':'MARK', 'mark-routing':'MARK (routing)', 'change-dscp':'DSCP', 'change-mss':'TCPMSS', 'change-ttl':'TTL / HL', 'set-priority':'CLASSIFY', 'passthrough':'COUNT / continue', 'jump':'JUMP'})[name] || name.toUpperCase(); } 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 `${escapeHtml(nativeFirewallTarget(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 === 'filter') renderFilterRules(); else if (tab === 'nat') renderNATRules(); else if (tab === 'mangle') renderMangleRules(); else if (tab === 'addrlist') renderAddressLists(); else if (tab === 'geo') loadGeoIP(); else if (tab === 'connections') loadConnections(); setTimeout(initTableSorting, 50); } 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(); const applyError = document.getElementById('fw-apply-error'); if (applyError) { applyError.textContent = state.fwSummary?.last_error ? 'Firewall configuration was not applied: ' + state.fwSummary.last_error : ''; applyError.classList.toggle('hidden', !state.fwSummary?.last_error); } 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(); if (applyError) { const messages=[state.fwSummary?.last_error, state.fwSummary?.connections_error, state.geoStatus?.last_error && 'GeoIP: '+state.geoStatus.last_error].filter(Boolean); applyError.textContent=messages.join(' · '); applyError.classList.toggle('hidden', !messages.length); } // 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; const sigFilter = (state.filterRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';'); const sigNat = (state.natRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';'); const sigMangle = (state.mangleRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';'); const sigAddr = (state.addressLists || []).map(a => JSON.stringify(a)).join(';'); const tbodyFilter = document.getElementById('filter-table-body'); if (tbodyFilter) { if (populateDropdowns || tbodyFilter.dataset.sig !== sigFilter) { tbodyFilter.dataset.sig = sigFilter; renderFilterRules(); } else { // In-place live stats update (ZERO flicker) (state.filterRules || []).forEach(r => { const p = document.getElementById(`filter-pkts-${r.id}`); if (p) p.innerText = `${(r.packets || 0).toLocaleString()}p`; const b = document.getElementById(`filter-bytes-${r.id}`); if (b) b.innerText = formatTraffic(r.bytes || 0).replace('/s', ''); }); } } const tbodyNat = document.getElementById('nat-table-body'); if (tbodyNat) { if (populateDropdowns || tbodyNat.dataset.sig !== sigNat) { tbodyNat.dataset.sig = sigNat; renderNATRules(); } else { (state.natRules || []).forEach(r => { const p = document.getElementById(`nat-pkts-${r.id}`); if (p) p.innerText = `${(r.packets || 0).toLocaleString()}p`; const b = document.getElementById(`nat-bytes-${r.id}`); if (b) b.innerText = formatTraffic(r.bytes || 0).replace('/s', ''); }); } } const tbodyMangle = document.getElementById('mangle-table-body'); if (tbodyMangle) { if (populateDropdowns || tbodyMangle.dataset.sig !== sigMangle) { tbodyMangle.dataset.sig = sigMangle; renderMangleRules(); } else { (state.mangleRules || []).forEach(r => { const p = document.getElementById(`mangle-pkts-${r.id}`); if (p) p.innerText = `${(r.packets || 0).toLocaleString()}p`; const b = document.getElementById(`mangle-bytes-${r.id}`); if (b) b.innerText = formatTraffic(r.bytes || 0).replace('/s', ''); }); } } const tbodyAddr = document.getElementById('addrlist-table-body'); if (tbodyAddr && (populateDropdowns || tbodyAddr.dataset.sig !== sigAddr)) { tbodyAddr.dataset.sig = sigAddr; 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 || '').toLowerCase() === 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((raw, idx) => { const r = safeFirewallRow(raw); 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(' '); let actionExtra = ''; if (r.action === 'reject') { actionExtra = `(${r.reject_with || 'icmp-net-unreach'})`; } else if (r.action === 'jump') { actionExtra = `→ ${r.jump_target || '-'}`; } else if (r.action.includes('address-list') || r.action.includes('address list')) { actionExtra = `→ ${r.address_list || '-'}`; } else if (r.action === 'log' && r.log_prefix) { actionExtra = `${r.log_prefix}`; } tr.innerHTML = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${(r.chain || '').toUpperCase()} ${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'} IPv${r.ip_version || 4} ${ports || '-'} ${ifaces || '-'} ${renderActionBadge(r.action)}${actionExtra} ${(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 ruleLists = [...(state.filterRules||[]), ...(state.natRules||[]), ...(state.mangleRules||[])].flatMap(r => [r.src_address_list,r.dst_address_list,r.address_list]).filter(Boolean).map(n => n.replace(/^!/, '')).filter(n => !n.startsWith('geo:') && !n.startsWith('geo_')); const customNames = Array.from(new Set([...(state.addressLists || []).map(a => a.name), ...ruleLists])).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 && !Array.from(sel.options).some(option => option.value === cleanVal)) { const opt = document.createElement('option'); opt.value = cleanVal; opt.innerText = cleanVal; opt.selected = true; sel.appendChild(opt); } populateCommonAddrListDatalist(); } function populateCommonAddrListDatalist() { const dl = document.getElementById('fw-addrlist-datalist'); if (!dl) return; dl.innerHTML = ''; const names = Array.from(new Set((state.addressLists || []).map(a => a.name).filter(Boolean))).sort(); names.forEach(name => { const opt = document.createElement('option'); opt.value = name; dl.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); } // ==================== FIREWALL FILTER MODAL (DEBTIK WEBFIG / WINBOX V4) ==================== function switchFwRuleTab(tab) { state.activeFwRuleTab = tab; document.querySelectorAll('.fw-rule-tab-btn').forEach(btn => { btn.classList.remove('bg-[#182638]', 'text-white', 'border-sky-400'); btn.classList.add('text-slate-400', 'border-transparent'); }); const activeBtn = document.getElementById(`fw-tab-btn-${tab}`); if (activeBtn) { activeBtn.classList.remove('text-slate-400', 'border-transparent'); activeBtn.classList.add('bg-[#182638]', 'text-white', 'border-sky-400'); } document.querySelectorAll('.fw-rule-pane').forEach(p => p.classList.add('hidden')); const activePane = document.getElementById(`fw-pane-${tab}`); if (activePane) activePane.classList.remove('hidden'); if (tab === 'statistics') { startFwStatPolling(); } else { stopFwStatPolling(); } } function toggleFwField(fieldKey, expand) { const plusBtn = document.getElementById(`fw-plus-${fieldKey}`); const fieldBox = document.getElementById(`fw-field-${fieldKey}`); if (!plusBtn || !fieldBox) return; if (expand) { plusBtn.classList.add('hidden'); fieldBox.classList.remove('hidden'); const valInput = document.getElementById(`fw-val-${fieldKey}`); if (valInput) valInput.focus(); } else { fieldBox.classList.add('hidden'); plusBtn.classList.remove('hidden'); const valInput = document.getElementById(`fw-val-${fieldKey}`); if (valInput) { if (valInput.tagName === 'SELECT') { valInput.selectedIndex = 0; } else { valInput.value = ''; } } const invInput = document.getElementById(`fw-inv-${fieldKey}`); if (invInput) invInput.checked = false; if (fieldKey === 'cs') { ['filter-modal-cs-established', 'filter-modal-cs-related', 'filter-modal-cs-new', 'filter-modal-cs-invalid', 'filter-modal-cs-untracked'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); } if (fieldKey === 'natstate') { ['filter-modal-nat-srcnat', 'filter-modal-nat-dstnat'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); } if (fieldKey === 'days') { ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => { const el = document.getElementById(`fw-day-${d}`); if (el) el.checked = true; }); } } } function setFwFieldVal(fieldKey, value, hasInvert = false) { if (fieldKey === 'ipsec' && typeof value === 'string') value = value.replace(':', ','); if (value !== undefined && value !== null && value !== '' && !(fieldKey === 'proto' && value === 'all')) { toggleFwField(fieldKey, true); let strVal = String(value); if (hasInvert) { const inv = strVal.startsWith('!'); if (inv) strVal = strVal.slice(1); const invEl = document.getElementById(`fw-inv-${fieldKey}`); if (invEl) invEl.checked = inv; else if (inv) strVal = '!'+strVal; } const valEl = document.getElementById(`fw-val-${fieldKey}`); if (valEl) { if (valEl.tagName === 'SELECT' && !Array.from(valEl.options).some(o => o.value === strVal)) { const option=document.createElement('option'); option.value=strVal; option.textContent=strVal+' (saved)'; valEl.appendChild(option); } valEl.value = strVal; } } else { toggleFwField(fieldKey, false); } } function getFwFieldVal(fieldKey, hasInvert = false) { const fieldBox = document.getElementById(`fw-field-${fieldKey}`); if (!fieldBox || fieldBox.classList.contains('hidden')) return ''; const valEl = document.getElementById(`fw-val-${fieldKey}`); let v = valEl ? valEl.value.trim() : ''; if (!v) return ''; if (hasInvert) { const invEl = document.getElementById(`fw-inv-${fieldKey}`); if (invEl && invEl.checked) { v = '!' + v.replace(/^!/, ''); } } return v; } function toggleMaximizeFilterModal() { const win = document.getElementById('filter-modal-window'); if (!win) return; if (win.classList.contains('max-w-[720px]')) { win.classList.remove('max-w-[720px]', 'max-h-[92vh]'); win.classList.add('max-w-[96vw]', 'h-[94vh]'); } else { win.classList.remove('max-w-[96vw]', 'h-[94vh]'); win.classList.add('max-w-[720px]', 'max-h-[92vh]'); } } function openAddFilterModal() { const tos = document.getElementById('fw-val-tos'); if (tos) tos.value = ''; switchFwRuleTab('general'); setFirewallDays('fw', ''); const badge = document.getElementById('fw-modal-badge'); if (badge) badge.innerText = 'New...'; document.getElementById('filter-modal-id').value = ''; document.getElementById('filter-modal-family').value = '4'; document.getElementById('filter-modal-enabled').checked = true; document.getElementById('filter-modal-comment').value = ''; document.getElementById('filter-modal-chain').value = 'forward'; document.getElementById('filter-modal-action').value = 'accept'; document.getElementById('filter-modal-log').checked = false; populateInterfaceSelect('fw-val-iniface'); populateInterfaceSelect('fw-val-outiface'); populateAddressListSelect('fw-val-srclist'); populateAddressListSelect('fw-val-dstlist'); populateGeoIPSelect('fw-val-srcgeo'); populateGeoIPSelect('fw-val-dstgeo'); const allFieldKeys = [ 'src', 'dst', 'srclist', 'dstlist', 'srcgeo', 'dstgeo', 'proto', 'sport', 'dport', 'anyport', 'iniface', 'outiface', 'inifacelist', 'outifacelist', 'pktmark', 'connmark', 'routingmark', 'conntype', 'cs', 'natstate', 'layer7', 'content', 'connbytes', 'connrate', 'pcc', 'srcmac', 'outbridgeport', 'inbridgeport', 'inbridgeportlist', 'outbridgeportlist', 'ipsec', 'tlshost', 'ingresspriority', 'priority', 'dscp', 'tcpmss', 'pktsize', 'random', 'tcpflags', 'icmpoptions', 'ipv4options', 'ttl', 'time', 'days', 'limit', 'dstlimit', 'psd', 'logprefix' ]; allFieldKeys.forEach(k => toggleFwField(k, false)); resetRuleCounterDisplay(); toggleFilterActionFields(); refreshFirewallRejectTypes(); document.getElementById('filter-modal').classList.remove('hidden'); } function openEditFilterModal(id) { id = decodeURIComponent(id); const r = (state.filterRules || []).find(rule => rule.id === id); if (!r) return; switchFwRuleTab('general'); const badge = document.getElementById('fw-modal-badge'); if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`; document.getElementById('filter-modal-id').value = r.id; document.getElementById('filter-modal-family').value = String(r.ip_version || 4); document.getElementById('filter-modal-enabled').checked = !r.disabled; document.getElementById('filter-modal-comment').value = r.comment || ''; document.getElementById('filter-modal-chain').value = r.chain || 'forward'; setFirewallActionSelection('filter', r.action || 'accept'); document.getElementById('filter-modal-log').checked = !!r.log; populateInterfaceSelect('fw-val-iniface', (r.in_interface || '').replace(/^!/, '')); populateInterfaceSelect('fw-val-outiface', (r.out_interface || '').replace(/^!/, '')); populateAddressListSelect('fw-val-srclist', (r.src_address_list || '').replace(/^!/, '')); populateAddressListSelect('fw-val-dstlist', (r.dst_address_list || '').replace(/^!/, '')); populateGeoIPSelect('fw-val-srcgeo', r.src_geoip || ''); populateGeoIPSelect('fw-val-dstgeo', r.dst_geoip || ''); setFwFieldVal('src', r.src_address, true); setFwFieldVal('dst', r.dst_address, true); setFwFieldVal('srclist', r.src_address_list, true); setFwFieldVal('dstlist', r.dst_address_list, true); setFwFieldVal('srcgeo', r.src_geoip, true); setFwFieldVal('dstgeo', r.dst_geoip, true); if (r.protocol && r.protocol !== 'all') { setFwFieldVal('proto', r.protocol, true); } else { toggleFwField('proto', false); } setFwFieldVal('sport', r.src_port, true); setFwFieldVal('dport', r.dst_port, true); setFwFieldVal('anyport', r.any_port, true); setFwFieldVal('iniface', r.in_interface, true); setFwFieldVal('outiface', r.out_interface, true); setFwFieldVal('inifacelist', r.in_interface_list, true); setFwFieldVal('outifacelist', r.out_interface_list, true); setFwFieldVal('pktmark', r.packet_mark, true); setFwFieldVal('connmark', r.connection_mark, true); setFwFieldVal('routingmark', r.routing_mark, true); setFwFieldVal('conntype', r.connection_type, false); const states = r.connection_state || []; if (states.length > 0) { toggleFwField('cs', true); 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'); document.getElementById('filter-modal-cs-untracked').checked = states.includes('untracked'); } else { toggleFwField('cs', false); } const natStates = r.connection_nat_state || []; if (natStates.length > 0) { toggleFwField('natstate', true); document.getElementById('filter-modal-nat-srcnat').checked = natStates.includes('srcnat'); document.getElementById('filter-modal-nat-dstnat').checked = natStates.includes('dstnat'); } else { toggleFwField('natstate', false); } // Advanced tab fields setFwFieldVal('layer7', r.layer7_protocol, false); setFwFieldVal('content', r.content, false); setFwFieldVal('connbytes', r.connection_bytes, false); setFwFieldVal('connrate', r.connection_rate, false); setFwFieldVal('pcc', r.pcc, false); setFwFieldVal('srcmac', r.src_mac_address, true); setFwFieldVal('outbridgeport', r.out_bridge_port, false); setFwFieldVal('inbridgeport', r.in_bridge_port, false); setFwFieldVal('inbridgeportlist', r.in_bridge_port_list, false); setFwFieldVal('outbridgeportlist', r.out_bridge_port_list, false); setFwFieldVal('ipsec', r.ipsec_policy, false); setFwFieldVal('tlshost', r.tls_host, false); setFwFieldVal('ingresspriority', r.ingress_priority, false); setFwFieldVal('priority', r.priority, false); setFwFieldVal('dscp', r.dscp, false); setFwFieldVal('tcpmss', r.tcp_mss, false); { const tosEl = document.getElementById('fw-val-tos'); if (tosEl) tosEl.value = r.tos || ''; } setFwFieldVal('pktsize', r.packet_size, false); setFwFieldVal('random', r.random, false); setFwFieldVal('tcpflags', r.tcp_flags, false); setFwFieldVal('icmpoptions', r.icmp_options, false); setFwFieldVal('ipv4options', r.ipv4_options, false); setFwFieldVal('ttl', r.ttl, false); // Extra tab fields setFwFieldVal('time', r.time, false); if (r.days) { toggleFwField('days', true); ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => { const chk = document.getElementById(`fw-day-${d}`); if (chk) chk.checked = r.days.toLowerCase().includes(d); }); } else { toggleFwField('days', false); } setFwFieldVal('limit', r.limit, false); setFwFieldVal('dstlimit', r.dst_limit, false); setFwFieldVal('psd', r.psd, false); // Action fields if (r.reject_with) { const rwEl = document.getElementById('filter-modal-rejectwith'); if (rwEl) rwEl.value = r.reject_with; } if (r.jump_target) { const jtEl = document.getElementById('filter-modal-jumptarget'); if (jtEl) jtEl.value = r.jump_target; } if (r.address_list) { const alEl = document.getElementById('filter-modal-action-addrlist'); if (alEl) alEl.value = r.address_list; } if (r.address_list_timeout) { const toEl = document.getElementById('filter-modal-action-timeout'); if (toEl) toEl.value = r.address_list_timeout; } setFwFieldVal('logprefix', r.log_prefix, false); // Statistics const bEl = document.getElementById('fw-stat-bytes'); const pEl = document.getElementById('fw-stat-packets'); if (bEl) bEl.value = formatTraffic(r.bytes || 0).replace('/s', ''); if (pEl) pEl.value = (r.packets || 0).toLocaleString(); state.fwLastBytes = r.bytes || 0; state.fwLastPackets = r.packets || 0; state.fwLastTimestamp = Date.now(); state.fwStatRateHistory = new Array(30).fill(0); state.fwStatPktHistory = new Array(30).fill(0); renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory, 'bps', 'fw-stat-legend-rate'); renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory, 'p/s', 'fw-stat-legend-pktrate'); toggleFilterActionFields(); refreshFirewallRejectTypes(); document.getElementById('filter-modal').classList.remove('hidden'); } function closeFilterModal() { stopFwStatPolling(); document.getElementById('filter-modal').classList.add('hidden'); } function toggleFilterActionFields() { const act = document.getElementById('filter-modal-action')?.value || 'accept'; const rejectBox = document.getElementById('filter-action-reject-box'); const jumpBox = document.getElementById('filter-action-jump-box'); const addrBox = document.getElementById('filter-action-addrlist-box'); if (rejectBox) { if (act === 'reject') rejectBox.classList.remove('hidden'); else rejectBox.classList.add('hidden'); } if (jumpBox) { if (act === 'jump') jumpBox.classList.remove('hidden'); else jumpBox.classList.add('hidden'); } if (addrBox) { if (act === 'add-dst-to-address-list' || act === 'add-src-to-address-list' || act.includes('address-list') || act.includes('address list')) { addrBox.classList.remove('hidden'); } else { addrBox.classList.add('hidden'); } } if (act === 'log') { const logEl = document.getElementById('filter-modal-log'); if (logEl) logEl.checked = true; toggleFwField('logprefix', true); } } function toggleFwLogPrefix() { const isLog = document.getElementById('filter-modal-log')?.checked; toggleFwField('logprefix', !!isLog); } function copyFilterRule() { document.getElementById('filter-modal-id').value = ''; const badge = document.getElementById('fw-modal-badge'); if (badge) badge.innerText = 'New...'; showToast('Rule Cloned', 'Ready to save as a new rule copy', 'info'); } function resetRuleCounterDisplay() { state.fwStatRateHistory = new Array(30).fill(0); state.fwStatPktHistory = new Array(30).fill(0); state.fwLastBytes = 0; state.fwLastPackets = 0; state.fwLastTimestamp = Date.now(); const bEl = document.getElementById('fw-stat-bytes'); const pEl = document.getElementById('fw-stat-packets'); const rEl = document.getElementById('fw-stat-rate'); const prEl = document.getElementById('fw-stat-pktrate'); if (bEl) bEl.value = '0 B'; if (pEl) pEl.value = '0'; if (rEl) rEl.value = '0 bps'; if (prEl) prEl.value = '0 p/s'; renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory, 'bps', 'fw-stat-legend-rate'); renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory, 'p/s', 'fw-stat-legend-pktrate'); showToast('Counters Reset', 'Rule counter display reset to 0', 'info'); } async function resetAllFwCounters() { try { const res = await fetch(`${API_BASE}/firewall/reset-counters`, { method: 'POST' }); if (res.ok) { resetRuleCounterDisplay(); showToast('All Counters Reset', 'All firewall counters zeroed', 'success'); loadFirewall(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function applyFilterRule(closeAfter = false) { const id = document.getElementById('filter-modal-id').value; if (!reviewLegacyFirewallSettings('filter', id)) return; let selectedDays; try { selectedDays = getFirewallDays('fw'); } catch(error) { showToast('Error', error.message, 'error'); return; } const chain = document.getElementById('filter-modal-chain').value; const action = document.getElementById('filter-modal-action').value; const comment = document.getElementById('filter-modal-comment').value.trim(); const enabled = document.getElementById('filter-modal-enabled').checked; const src = getFwFieldVal('src', true); const dst = getFwFieldVal('dst', true); const srclist = getFwFieldVal('srclist', true); const dstlist = getFwFieldVal('dstlist', true); const srcgeo = getFwFieldVal('srcgeo', true); const dstgeo = getFwFieldVal('dstgeo', true); const proto = getFwFieldVal('proto', true); const sport = getFwFieldVal('sport', true); const dport = getFwFieldVal('dport', true); const anyport = getFwFieldVal('anyport', true); if ((sport || dport || anyport) && !['tcp', 'udp'].includes(proto)) { switchFwRuleTab('general'); toggleFwField('proto', true); showToast('Select protocol', 'Port matches require TCP or UDP without protocol inversion.', 'error'); document.getElementById('fw-val-proto').focus(); return; } const iniface = getFwFieldVal('iniface', true); const outiface = getFwFieldVal('outiface', true); const inifacelist = getFwFieldVal('inifacelist', true); const outifacelist = getFwFieldVal('outifacelist', true); const pktmark = getFwFieldVal('pktmark', true); const connmark = getFwFieldVal('connmark', true); const routingmark = getFwFieldVal('routingmark', true); const conntype = getFwFieldVal('conntype', false); const states = []; const csBox = document.getElementById('fw-field-cs'); if (csBox && !csBox.classList.contains('hidden')) { 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'); if (document.getElementById('filter-modal-cs-untracked')?.checked) states.push('untracked'); } const natStates = []; const natBox = document.getElementById('fw-field-natstate'); if (natBox && !natBox.classList.contains('hidden')) { if (document.getElementById('filter-modal-nat-srcnat')?.checked) natStates.push('srcnat'); if (document.getElementById('filter-modal-nat-dstnat')?.checked) natStates.push('dstnat'); } const payload = { ip_version: Number(document.getElementById('filter-modal-family').value), id, chain, action, protocol: proto || 'all', src_address: src, dst_address: dst, src_port: sport, dst_port: dport, any_port: anyport, in_interface: iniface, out_interface: outiface, in_interface_list: inifacelist, out_interface_list: outifacelist, packet_mark: pktmark, connection_mark: connmark, routing_mark: routingmark, connection_type: conntype, connection_state: states, connection_nat_state: natStates, src_address_list: srclist, dst_address_list: dstlist, src_geoip: srcgeo, dst_geoip: dstgeo, comment, disabled: !enabled, // Advanced tab content: getFwFieldVal('content', false), src_mac_address: getFwFieldVal('srcmac', true), out_bridge_port: getFwFieldVal('outbridgeport', false), in_bridge_port: getFwFieldVal('inbridgeport', false), ipsec_policy: getFwFieldVal('ipsec', false), dscp: getFwFieldVal('dscp', false), tcp_mss: getFwFieldVal('tcpmss', false), tos: document.getElementById('fw-val-tos')?.value || '', packet_size: getFwFieldVal('pktsize', false), random: getFwFieldVal('random', false), tcp_flags: getFwFieldVal('tcpflags', false), icmp_options: getFwFieldVal('icmpoptions', false), ttl: getFwFieldVal('ttl', false), days: selectedDays, // Extra tab time: getFwFieldVal('time', false), limit: getFwFieldVal('limit', false), // Action tab reject_with: action === 'reject' ? (document.getElementById('filter-modal-rejectwith')?.value || '') : '', jump_target: action === 'jump' ? (document.getElementById('filter-modal-jumptarget')?.value || '') : '', address_list: action.startsWith('add-') ? (document.getElementById('filter-modal-action-addrlist')?.value || '') : '', address_list_timeout: action.startsWith('add-') ? (document.getElementById('filter-modal-action-timeout')?.value || '') : '', log: document.getElementById('filter-modal-log')?.checked || false, log_prefix: getFwFieldVal('logprefix', false) }; try { const res = await fetch(`${API_BASE}/firewall/filter`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { const data = await res.json(); showToast('Filter Rule Saved', 'Rule applied successfully to firewall', 'success'); loadFirewall(false); if (closeAfter) { closeFilterModal(); } else { if (data.id) { document.getElementById('filter-modal-id').value = data.id; const badge = document.getElementById('fw-modal-badge'); if (badge) badge.innerText = `#${data.id}`; } } } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save rule', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function submitSaveFilterRule(e) { if (e && e.preventDefault) e.preventDefault(); return applyFilterRule(true); } function startFwStatPolling() { stopFwStatPolling(); pollRuleStats(); state.fwStatInterval = setInterval(pollRuleStats, 1000); } function stopFwStatPolling() { if (state.fwStatInterval) { clearInterval(state.fwStatInterval); state.fwStatInterval = null; } } async function pollRuleStats() { const id = document.getElementById('filter-modal-id')?.value; if (!id) { renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory || new Array(30).fill(0), 'bps', 'fw-stat-legend-rate'); renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory || new Array(30).fill(0), 'p/s', 'fw-stat-legend-pktrate'); return; } try { const res = await fetch(`${API_BASE}/firewall/filter`); if (!res.ok) return; const rules = await res.json(); const r = rules.find(x => x.id === id); if (!r) return; const now = Date.now(); const dt = Math.max(0.2, (now - (state.fwLastTimestamp || now)) / 1000); state.fwLastTimestamp = now; const curBytes = r.bytes || 0; const curPackets = r.packets || 0; const deltaBytes = Math.max(0, curBytes - (state.fwLastBytes ?? curBytes)); const deltaPkts = Math.max(0, curPackets - (state.fwLastPackets ?? curPackets)); state.fwLastBytes = curBytes; state.fwLastPackets = curPackets; const rateBps = Math.round((deltaBytes * 8) / dt); const ratePkt = Math.round(deltaPkts / dt); if (!state.fwStatRateHistory) state.fwStatRateHistory = new Array(30).fill(0); if (!state.fwStatPktHistory) state.fwStatPktHistory = new Array(30).fill(0); state.fwStatRateHistory.push(rateBps); if (state.fwStatRateHistory.length > 30) state.fwStatRateHistory.shift(); state.fwStatPktHistory.push(ratePkt); if (state.fwStatPktHistory.length > 30) state.fwStatPktHistory.shift(); const bEl = document.getElementById('fw-stat-bytes'); const pEl = document.getElementById('fw-stat-packets'); const rEl = document.getElementById('fw-stat-rate'); const prEl = document.getElementById('fw-stat-pktrate'); if (bEl) bEl.value = formatTraffic(curBytes).replace('/s', ''); if (pEl) pEl.value = curPackets.toLocaleString(); if (rEl) rEl.value = formatBps(rateBps); if (prEl) prEl.value = `${ratePkt.toLocaleString()} p/s`; renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory, 'bps', 'fw-stat-legend-rate'); renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory, 'p/s', 'fw-stat-legend-pktrate'); } catch (e) { console.error('Error polling rule stats', e); } } function formatBps(bps) { if (bps >= 1000000000) return (bps / 1000000000).toFixed(1) + ' Gbps'; if (bps >= 1000000) return (bps / 1000000).toFixed(1) + ' Mbps'; if (bps >= 1000) return (bps / 1000).toFixed(1) + ' kbps'; return bps + ' bps'; } function renderFwSparkline(svgId, dataArray, unit, legendId) { const svg = document.getElementById(svgId); if (!svg) return; const w = 500; const h = 100; const paddingRight = 45; const graphWidth = w - paddingRight; const maxVal = Math.max(5, ...dataArray); const currentVal = dataArray[dataArray.length - 1] || 0; const leg = document.getElementById(legendId); if (leg) { leg.innerText = unit === 'bps' ? formatBps(currentVal) : `${currentVal.toLocaleString()} p/s`; } let gridLines = ''; for (let i = 0; i <= 5; i++) { const y = 8 + (i * (h - 18) / 5); const valAtLevel = Math.round(maxVal * (5 - i) / 5); const label = unit === 'bps' ? formatBps(valAtLevel) : `${valAtLevel} p/s`; gridLines += ` ${label} `; } const step = graphWidth / (dataArray.length - 1); const points = dataArray.map((val, idx) => { const x = idx * step; const y = (h - 10) - (val / maxVal) * (h - 20); return `${x.toFixed(1)},${y.toFixed(1)}`; }); const polylineStr = points.join(' '); const areaPath = `M 0,${h - 10} L ${polylineStr} L ${graphWidth},${h - 10} Z`; svg.innerHTML = ` ${gridLines} `; } async function moveFilterRule(id, dir) { id = decodeURIComponent(id); 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) throw new Error(await responseError(res)); await loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleFilterRuleState(id, disabled) { id = decodeURIComponent(id); 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) throw new Error(await responseError(res)); await loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteFilterRule(id) { id = decodeURIComponent(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) throw new Error(await responseError(res)); { 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 || nativeNATChain(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((raw, idx) => { const r = safeFirewallRow(raw); const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`; let toTarget = '-'; if (r.action === 'jump') { toTarget = `→ ${r.jump_target || '-'}`; } else if (r.action.includes('address-list') || r.action.includes('address list')) { toTarget = `→ ${r.address_list || '-'}`; } else if (r.action === 'redirect') { toTarget = `ports:${r.to_ports || 'all'}`; } else if (r.action === 'masquerade') { toTarget = r.to_ports ? `ports:${r.to_ports}` : `-`; } else if (r.action === 'log') { toTarget = `${r.log_prefix || 'log'}`; } else { const parts = [r.to_addresses, r.to_ports].filter(Boolean); toTarget = parts.length ? `${parts.join(':')}` : '-'; } const ifaces = [r.out_interface ? 'out:' + r.out_interface : r.out_interface_list ? 'out-list:' + r.out_interface_list : '', r.in_interface ? 'in:' + r.in_interface : r.in_interface_list ? 'in-list:' + r.in_interface_list : ''].filter(Boolean).join(' '); tr.innerHTML = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${escapeHtml(nativeNATChain(raw.chain).toUpperCase())} ${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 : ''} IPv${r.ip_version || 4} ${ifaces || '-'} ${renderActionBadge(r.action)} ${toTarget} ${(r.packets || 0).toLocaleString()}p / ${formatTraffic(r.bytes || 0).replace('/s', '')} ${r.comment || '-'} `; tbody.appendChild(tr); }); } // ==================== FIREWALL NAT MODAL (DEBTIK WEBFIG / WINBOX V4) ==================== function switchNATRuleTab(tab) { if (!document.getElementById(`nat-pane-${tab}`)) tab = 'general'; state.activeNATRuleTab = tab; document.querySelectorAll('.nat-rule-tab-btn').forEach(btn => { btn.classList.remove('bg-[#182638]', 'text-white', 'border-sky-400'); btn.classList.add('text-slate-400', 'border-transparent'); }); const activeBtn = document.getElementById(`nat-tab-btn-${tab}`); if (activeBtn) { activeBtn.classList.remove('text-slate-400', 'border-transparent'); activeBtn.classList.add('bg-[#182638]', 'text-white', 'border-sky-400'); } document.querySelectorAll('.nat-rule-pane').forEach(p => p.classList.add('hidden')); const activePane = document.getElementById(`nat-pane-${tab}`); if (activePane) activePane.classList.remove('hidden'); if (tab === 'statistics') { startNATStatPolling(); } else { stopNATStatPolling(); } } function toggleNATField(fieldKey, expand) { const plusBtn = document.getElementById(`nat-plus-${fieldKey}`); const fieldBox = document.getElementById(`nat-field-${fieldKey}`); if (!plusBtn || !fieldBox) return; if (expand) { plusBtn.classList.add('hidden'); fieldBox.classList.remove('hidden'); const valInput = document.getElementById(`nat-val-${fieldKey}`); if (valInput) valInput.focus(); } else { fieldBox.classList.add('hidden'); plusBtn.classList.remove('hidden'); const valInput = document.getElementById(`nat-val-${fieldKey}`); if (valInput) { if (valInput.tagName === 'SELECT') { valInput.selectedIndex = 0; } else { valInput.value = ''; } } const invInput = document.getElementById(`nat-inv-${fieldKey}`); if (invInput) invInput.checked = false; if (fieldKey === 'days') { ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => { const el = document.getElementById(`nat-day-${d}`); if (el) el.checked = true; }); } } } function setNATFieldVal(fieldKey, value, hasInvert = false) { if (fieldKey === 'ipsec' && typeof value === 'string') value = value.replace(':', ','); if (value !== undefined && value !== null && value !== '' && !(fieldKey === 'proto' && value === 'all')) { toggleNATField(fieldKey, true); let strVal = String(value); if (hasInvert) { const inv = strVal.startsWith('!'); if (inv) strVal = strVal.slice(1); const invEl = document.getElementById(`nat-inv-${fieldKey}`); if (invEl) invEl.checked = inv; else if (inv) strVal = '!'+strVal; } const valEl = document.getElementById(`nat-val-${fieldKey}`); if (valEl) { if (valEl.tagName === 'SELECT' && !Array.from(valEl.options).some(o => o.value === strVal)) { const option=document.createElement('option'); option.value=strVal; option.textContent=strVal+' (saved)'; valEl.appendChild(option); } valEl.value = strVal; } } else { toggleNATField(fieldKey, false); } } function getNATFieldVal(fieldKey, hasInvert = false) { const fieldBox = document.getElementById(`nat-field-${fieldKey}`); if (!fieldBox || fieldBox.classList.contains('hidden')) return ''; const valEl = document.getElementById(`nat-val-${fieldKey}`); let v = valEl ? valEl.value.trim() : ''; if (!v) return ''; if (hasInvert) { const invEl = document.getElementById(`nat-inv-${fieldKey}`); if (invEl && invEl.checked) { v = '!' + v.replace(/^!/, ''); } } return v; } function toggleMaximizeNATModal() { const win = document.getElementById('nat-modal-window'); if (!win) return; if (win.classList.contains('max-w-[720px]')) { win.classList.remove('max-w-[720px]', 'max-h-[92vh]'); win.classList.add('max-w-[96vw]', 'h-[94vh]'); } else { win.classList.remove('max-w-[96vw]', 'h-[94vh]'); win.classList.add('max-w-[720px]', 'max-h-[92vh]'); } } function handleNATChainChange() { toggleNATActionFields(); } function toggleNATActionFields() { const act = document.getElementById('nat-modal-action')?.value || 'masquerade'; const rowAddr = document.getElementById('nat-row-toaddr'); const rowPort = document.getElementById('nat-row-toport'); const jumpBox = document.getElementById('nat-action-jump-box'); const addrBox = document.getElementById('nat-action-addrlist-box'); // Hide all dynamic rows first if (rowAddr) rowAddr.classList.add('hidden'); if (rowPort) rowPort.classList.add('hidden'); if (jumpBox) jumpBox.classList.add('hidden'); if (addrBox) addrBox.classList.add('hidden'); if (['src-nat', 'dst-nat', 'netmap'].includes(act)) { if (rowAddr) rowAddr.classList.remove('hidden'); if (rowPort && act !== 'netmap') rowPort.classList.remove('hidden'); } else if (act === 'redirect' || act === 'masquerade') { if (rowPort) rowPort.classList.remove('hidden'); } else if (act === 'jump') { if (jumpBox) jumpBox.classList.remove('hidden'); } else if (act.includes('address-list') || act.includes('address list')) { if (addrBox) addrBox.classList.remove('hidden'); } if (act === 'log') { const logEl = document.getElementById('nat-modal-log'); if (logEl) logEl.checked = true; toggleNATField('logprefix', true); } } function handleNATActionChange() { toggleNATActionFields(); } function toggleNATLogPrefix() { const isLog = document.getElementById('nat-modal-log')?.checked; toggleNATField('logprefix', !!isLog); } function openAddNATModal() { const tos = document.getElementById('nat-val-tos'); if (tos) tos.value = ''; switchNATRuleTab('general'); setFirewallDays('nat', ''); const badge = document.getElementById('nat-modal-badge'); if (badge) badge.innerText = 'New...'; document.getElementById('nat-modal-id').value = ''; document.getElementById('nat-modal-family').value = '4'; document.getElementById('nat-modal-enabled').checked = true; document.getElementById('nat-modal-comment').value = ''; document.getElementById('nat-modal-chain').value = 'postrouting'; document.getElementById('nat-modal-action').value = 'masquerade'; document.getElementById('nat-modal-toaddr').value = ''; document.getElementById('nat-modal-toport').value = ''; document.getElementById('nat-modal-log').checked = false; populateInterfaceSelect('nat-val-iniface'); populateInterfaceSelect('nat-val-outiface'); populateAddressListSelect('nat-val-srclist'); populateAddressListSelect('nat-val-dstlist'); populateGeoIPSelect('nat-val-srcgeo'); populateGeoIPSelect('nat-val-dstgeo'); const allFieldKeys = [ 'src', 'dst', 'srclist', 'dstlist', 'srcgeo', 'dstgeo', 'proto', 'sport', 'dport', 'anyport', 'iniface', 'outiface', 'inifacelist', 'outifacelist', 'pktmark', 'connmark', 'routingmark', 'conntype', 'layer7', 'content', 'connbytes', 'connrate', 'pcc', 'srcmac', 'outbridgeport', 'inbridgeport', 'inbridgeportlist', 'outbridgeportlist', 'ipsec', 'ingresspriority', 'priority', 'dscp', 'tcpmss', 'pktsize', 'random', 'icmpoptions', 'ipv4options', 'ttl', 'time', 'days', 'limit', 'dstlimit', 'psd', 'logprefix' ]; allFieldKeys.forEach(k => toggleNATField(k, false)); resetNATCounterDisplay(); toggleNATActionFields(); document.getElementById('nat-modal').classList.remove('hidden'); } function openEditNATModal(id) { id = decodeURIComponent(id); const r = (state.natRules || []).find(rule => rule.id === id); if (!r) return; setFirewallDays('nat', r.days || ''); switchNATRuleTab('general'); const badge = document.getElementById('nat-modal-badge'); if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`; document.getElementById('nat-modal-id').value = r.id; document.getElementById('nat-modal-family').value = String(r.ip_version || 4); document.getElementById('nat-modal-enabled').checked = !r.disabled; document.getElementById('nat-modal-comment').value = r.comment || ''; document.getElementById('nat-modal-chain').value = nativeNATChain(r.chain || 'postrouting'); setFirewallActionSelection('nat', r.action || 'accept'); document.getElementById('nat-modal-toaddr').value = r.to_addresses || ''; document.getElementById('nat-modal-toport').value = r.to_ports || ''; document.getElementById('nat-modal-log').checked = !!r.log; populateInterfaceSelect('nat-val-iniface', (r.in_interface || '').replace(/^!/, '')); populateInterfaceSelect('nat-val-outiface', (r.out_interface || '').replace(/^!/, '')); populateAddressListSelect('nat-val-srclist', (r.src_address_list || '').replace(/^!/, '')); populateAddressListSelect('nat-val-dstlist', (r.dst_address_list || '').replace(/^!/, '')); populateGeoIPSelect('nat-val-srcgeo', r.src_geoip || ''); populateGeoIPSelect('nat-val-dstgeo', r.dst_geoip || ''); setNATFieldVal('src', r.src_address, true); setNATFieldVal('dst', r.dst_address, true); setNATFieldVal('srclist', r.src_address_list, true); setNATFieldVal('dstlist', r.dst_address_list, true); setNATFieldVal('srcgeo', r.src_geoip, true); setNATFieldVal('dstgeo', r.dst_geoip, true); if (r.protocol && r.protocol !== 'all') { setNATFieldVal('proto', r.protocol, true); } else { toggleNATField('proto', false); } setNATFieldVal('sport', r.src_port, true); setNATFieldVal('dport', r.dst_port, true); setNATFieldVal('anyport', r.any_port, true); setNATFieldVal('iniface', r.in_interface, true); setNATFieldVal('outiface', r.out_interface, true); setNATFieldVal('inifacelist', r.in_interface_list, true); setNATFieldVal('outifacelist', r.out_interface_list, true); setNATFieldVal('pktmark', r.packet_mark, true); setNATFieldVal('connmark', r.connection_mark, true); setNATFieldVal('routingmark', r.routing_mark, true); setNATFieldVal('conntype', r.connection_type, false); // Advanced tab fields setNATFieldVal('layer7', r.layer7_protocol, false); setNATFieldVal('content', r.content, false); setNATFieldVal('connbytes', r.connection_bytes, false); setNATFieldVal('connrate', r.connection_rate, false); setNATFieldVal('pcc', r.pcc, false); setNATFieldVal('srcmac', r.src_mac_address, true); setNATFieldVal('outbridgeport', r.out_bridge_port, false); setNATFieldVal('inbridgeport', r.in_bridge_port, false); setNATFieldVal('inbridgeportlist', r.in_bridge_port_list, false); setNATFieldVal('outbridgeportlist', r.out_bridge_port_list, false); setNATFieldVal('ipsec', r.ipsec_policy, false); setNATFieldVal('ingresspriority', r.ingress_priority, false); setNATFieldVal('priority', r.priority, false); setNATFieldVal('dscp', r.dscp, false); setNATFieldVal('tcpmss', r.tcp_mss, false); { const tosEl = document.getElementById('nat-val-tos'); if (tosEl) tosEl.value = r.tos || ''; } setNATFieldVal('pktsize', r.packet_size, false); setNATFieldVal('random', r.random, false); setNATFieldVal('icmpoptions', r.icmp_options, false); setNATFieldVal('ipv4options', r.ipv4_options, false); setNATFieldVal('ttl', r.ttl, false); // Extra tab fields setNATFieldVal('time', r.time, false); if (r.days) { toggleNATField('days', true); ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => { const chk = document.getElementById(`nat-day-${d}`); if (chk) chk.checked = r.days.toLowerCase().includes(d); }); } else { toggleNATField('days', false); } setNATFieldVal('limit', r.limit, false); setNATFieldVal('dstlimit', r.dst_limit, false); setNATFieldVal('psd', r.psd, false); // Action fields if (r.jump_target) { const jtEl = document.getElementById('nat-modal-jumptarget'); if (jtEl) jtEl.value = r.jump_target; } if (r.address_list) { const alEl = document.getElementById('nat-modal-action-addrlist'); if (alEl) alEl.value = r.address_list; } if (r.address_list_timeout) { const toEl = document.getElementById('nat-modal-action-timeout'); if (toEl) toEl.value = r.address_list_timeout; } setNATFieldVal('logprefix', r.log_prefix, false); // Statistics const bEl = document.getElementById('nat-stat-bytes'); const pEl = document.getElementById('nat-stat-packets'); if (bEl) bEl.value = formatTraffic(r.bytes || 0).replace('/s', ''); if (pEl) pEl.value = (r.packets || 0).toLocaleString(); state.natLastBytes = r.bytes || 0; state.natLastPackets = r.packets || 0; state.natLastTimestamp = Date.now(); state.natStatRateHistory = new Array(30).fill(0); state.natStatPktHistory = new Array(30).fill(0); renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory, 'bps', 'nat-stat-legend-rate'); renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory, 'p/s', 'nat-stat-legend-pktrate'); toggleNATActionFields(); document.getElementById('nat-modal').classList.remove('hidden'); } function closeNATModal() { stopNATStatPolling(); document.getElementById('nat-modal').classList.add('hidden'); } function copyNATRule() { document.getElementById('nat-modal-id').value = ''; const badge = document.getElementById('nat-modal-badge'); if (badge) badge.innerText = 'New...'; showToast('Rule Cloned', 'Ready to save as a new NAT rule copy', 'info'); } function resetNATCounterDisplay() { state.natStatRateHistory = new Array(30).fill(0); state.natStatPktHistory = new Array(30).fill(0); state.natLastBytes = 0; state.natLastPackets = 0; state.natLastTimestamp = Date.now(); const bEl = document.getElementById('nat-stat-bytes'); const pEl = document.getElementById('nat-stat-packets'); const rEl = document.getElementById('nat-stat-rate'); const prEl = document.getElementById('nat-stat-pktrate'); if (bEl) bEl.value = '0 B'; if (pEl) pEl.value = '0'; if (rEl) rEl.value = '0 bps'; if (prEl) prEl.value = '0 p/s'; renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory, 'bps', 'nat-stat-legend-rate'); renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory, 'p/s', 'nat-stat-legend-pktrate'); showToast('Counters Reset', 'NAT rule counter display reset to 0', 'info'); } async function applyNATRule(closeAfter = false) { const id = document.getElementById('nat-modal-id').value; if (!reviewLegacyFirewallSettings('nat', id)) return; let selectedDays; try { selectedDays = getFirewallDays('nat'); } catch(error) { showToast('Error', error.message, 'error'); return; } const chain = document.getElementById('nat-modal-chain').value; const action = document.getElementById('nat-modal-action').value; const comment = document.getElementById('nat-modal-comment').value.trim(); const enabled = document.getElementById('nat-modal-enabled').checked; const src = getNATFieldVal('src', true); const dst = getNATFieldVal('dst', true); const srclist = getNATFieldVal('srclist', true); const dstlist = getNATFieldVal('dstlist', true); const srcgeo = getNATFieldVal('srcgeo', true); const dstgeo = getNATFieldVal('dstgeo', true); const proto = getNATFieldVal('proto', true); const sport = getNATFieldVal('sport', true); const dport = getNATFieldVal('dport', true); const anyport = getNATFieldVal('anyport', true); const iniface = getNATFieldVal('iniface', true); const outiface = getNATFieldVal('outiface', true); const inifacelist = getNATFieldVal('inifacelist', true); const outifacelist = getNATFieldVal('outifacelist', true); const pktmark = getNATFieldVal('pktmark', true); const connmark = getNATFieldVal('connmark', true); const routingmark = getNATFieldVal('routingmark', true); const conntype = getNATFieldVal('conntype', false); const usesAddr = ['src-nat', 'dst-nat', 'netmap'].includes(action); const usesPort = ['src-nat', 'dst-nat', 'redirect', 'masquerade'].includes(action); if (!chain.trim()) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter a chain.', 'error'); document.getElementById('nat-modal-chain').focus(); return; } if (usesAddr && !document.getElementById('nat-modal-toaddr').value.trim()) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter the destination IP / address for this action.', 'error'); document.getElementById('nat-modal-toaddr').focus(); return; } if ((sport || dport || anyport || (usesPort && document.getElementById('nat-modal-toport').value.trim())) && !['tcp', 'udp'].includes(proto)) { switchNATRuleTab('general'); toggleNATField('proto', true); document.getElementById('nat-val-proto').value = proto || 'all'; showToast('Select protocol', 'Port rules require TCP or UDP. For DNS, create one UDP and one TCP rule.', 'error'); document.getElementById('nat-val-proto').focus(); return; } const payload = { ip_version: Number(document.getElementById('nat-modal-family').value), id, chain, action, to_addresses: usesAddr ? (document.getElementById('nat-modal-toaddr')?.value.trim() || '') : '', to_ports: usesPort ? (document.getElementById('nat-modal-toport')?.value.trim() || '') : '', protocol: proto || 'all', src_address: src, dst_address: dst, src_port: sport, dst_port: dport, any_port: anyport, in_interface: iniface, out_interface: outiface, in_interface_list: inifacelist, out_interface_list: outifacelist, packet_mark: pktmark, connection_mark: connmark, routing_mark: routingmark, connection_type: conntype, src_address_list: srclist, dst_address_list: dstlist, src_geoip: srcgeo, dst_geoip: dstgeo, comment, disabled: !enabled, // Advanced tab content: getNATFieldVal('content', false), src_mac_address: getNATFieldVal('srcmac', true), out_bridge_port: getNATFieldVal('outbridgeport', false), in_bridge_port: getNATFieldVal('inbridgeport', false), ipsec_policy: getNATFieldVal('ipsec', false), dscp: getNATFieldVal('dscp', false), tcp_mss: getNATFieldVal('tcpmss', false), tos: document.getElementById('nat-val-tos')?.value || '', packet_size: getNATFieldVal('pktsize', false), random: getNATFieldVal('random', false), icmp_options: getNATFieldVal('icmpoptions', false), ttl: getNATFieldVal('ttl', false), days: selectedDays, // Extra tab time: getNATFieldVal('time', false), limit: getNATFieldVal('limit', false), // Action tab jump_target: action === 'jump' ? (document.getElementById('nat-modal-jumptarget')?.value || '') : '', address_list: action.includes('address') ? (document.getElementById('nat-modal-action-addrlist')?.value || '') : '', address_list_timeout: action.includes('address') ? (document.getElementById('nat-modal-action-timeout')?.value || '') : '', log: document.getElementById('nat-modal-log')?.checked || false, log_prefix: getNATFieldVal('logprefix', false) }; try { const res = await fetch(`${API_BASE}/firewall/nat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { const data = await res.json(); showToast('NAT Rule Saved', 'NAT mapping updated', 'success'); loadFirewall(false); if (closeAfter) { closeNATModal(); } else { if (data.id) { document.getElementById('nat-modal-id').value = data.id; const badge = document.getElementById('nat-modal-badge'); if (badge) badge.innerText = `#${data.id}`; } } } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save rule', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function submitSaveNATRule(e) { if (e && e.preventDefault) e.preventDefault(); return applyNATRule(true); } function startNATStatPolling() { stopNATStatPolling(); pollNATRuleStats(); state.natStatInterval = setInterval(pollNATRuleStats, 1000); } function stopNATStatPolling() { if (state.natStatInterval) { clearInterval(state.natStatInterval); state.natStatInterval = null; } } async function pollNATRuleStats() { const id = document.getElementById('nat-modal-id')?.value; if (!id) { renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory || new Array(30).fill(0), 'bps', 'nat-stat-legend-rate'); renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory || new Array(30).fill(0), 'p/s', 'nat-stat-legend-pktrate'); return; } try { const res = await fetch(`${API_BASE}/firewall/nat`); if (!res.ok) return; const rules = await res.json(); const r = rules.find(x => x.id === id); if (!r) return; const now = Date.now(); const dt = Math.max(0.2, (now - (state.natLastTimestamp || now)) / 1000); state.natLastTimestamp = now; const curBytes = r.bytes || 0; const curPackets = r.packets || 0; const deltaBytes = Math.max(0, curBytes - (state.natLastBytes ?? curBytes)); const deltaPkts = Math.max(0, curPackets - (state.natLastPackets ?? curPackets)); state.natLastBytes = curBytes; state.natLastPackets = curPackets; const rateBps = Math.round((deltaBytes * 8) / dt); const ratePkt = Math.round(deltaPkts / dt); if (!state.natStatRateHistory) state.natStatRateHistory = new Array(30).fill(0); if (!state.natStatPktHistory) state.natStatPktHistory = new Array(30).fill(0); state.natStatRateHistory.push(rateBps); if (state.natStatRateHistory.length > 30) state.natStatRateHistory.shift(); state.natStatPktHistory.push(ratePkt); if (state.natStatPktHistory.length > 30) state.natStatPktHistory.shift(); const bEl = document.getElementById('nat-stat-bytes'); const pEl = document.getElementById('nat-stat-packets'); const rEl = document.getElementById('nat-stat-rate'); const prEl = document.getElementById('nat-stat-pktrate'); if (bEl) bEl.value = formatTraffic(curBytes).replace('/s', ''); if (pEl) pEl.value = curPackets.toLocaleString(); if (rEl) rEl.value = formatBps(rateBps); if (prEl) prEl.value = `${ratePkt.toLocaleString()} p/s`; renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory, 'bps', 'nat-stat-legend-rate'); renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory, 'p/s', 'nat-stat-legend-pktrate'); } catch (e) { console.error('Error polling NAT rule stats', e); } } async function moveNATRule(id, dir) { id = decodeURIComponent(id); 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) throw new Error(await responseError(res)); await loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleNATRuleState(id, disabled) { id = decodeURIComponent(id); 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) throw new Error(await responseError(res)); await loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteNATRule(id) { id = decodeURIComponent(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) throw new Error(await responseError(res)); { 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 || '').toLowerCase() === 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((raw, idx) => { const r = safeFirewallRow(raw); const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`; let markVal = '-'; if (r.new_connection_mark) markVal = `${r.new_connection_mark}`; else if (r.new_routing_mark) markVal = `${r.new_routing_mark}`; else if (r.new_packet_mark) markVal = `${r.new_packet_mark}`; else if (r.new_mss) markVal = `mss:${r.new_mss}`; else if (r.action === 'change-dscp') markVal = `dscp:${r.new_dscp}`; else if (r.new_ttl) markVal = `ttl:${r.new_ttl}`; else if (r.new_priority) markVal = `prio:${r.new_priority}`; else if (r.route) markVal = `route:${r.route}`; else if (r.sniff_target) markVal = `sniff:${r.sniff_target}`; else if (r.action === 'jump') markVal = `→ ${r.jump_target || '-'}`; else if (r.action.includes('address-list') || r.action.includes('address list')) markVal = `→ ${r.address_list || '-'}`; else if (r.action === 'log') markVal = `${r.log_prefix || 'log'}`; tr.innerHTML = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${(r.chain || '').toUpperCase()} ${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); }); } // ==================== FIREWALL MANGLE MODAL (DEBTIK WEBFIG / WINBOX V4) ==================== function switchMangleRuleTab(tab) { state.activeMangleRuleTab = tab; document.querySelectorAll('.mangle-rule-tab-btn').forEach(btn => { btn.classList.remove('bg-[#182638]', 'text-white', 'border-sky-400'); btn.classList.add('text-slate-400', 'border-transparent'); }); const activeBtn = document.getElementById(`mangle-tab-btn-${tab}`); if (activeBtn) { activeBtn.classList.remove('text-slate-400', 'border-transparent'); activeBtn.classList.add('bg-[#182638]', 'text-white', 'border-sky-400'); } document.querySelectorAll('.mangle-rule-pane').forEach(p => p.classList.add('hidden')); const activePane = document.getElementById(`mangle-pane-${tab}`); if (activePane) activePane.classList.remove('hidden'); if (tab === 'statistics') { startMangleStatPolling(); } else { stopMangleStatPolling(); } } function toggleMangleField(fieldKey, expand) { const plusBtn = document.getElementById(`mangle-plus-${fieldKey}`); const fieldBox = document.getElementById(`mangle-field-${fieldKey}`); if (!plusBtn || !fieldBox) return; if (expand) { plusBtn.classList.add('hidden'); fieldBox.classList.remove('hidden'); const valInput = document.getElementById(`mangle-val-${fieldKey}`); if (valInput) valInput.focus(); } else { fieldBox.classList.add('hidden'); plusBtn.classList.remove('hidden'); const valInput = document.getElementById(`mangle-val-${fieldKey}`); if (valInput) { if (valInput.tagName === 'SELECT') { valInput.selectedIndex = 0; } else { valInput.value = ''; } } const invInput = document.getElementById(`mangle-inv-${fieldKey}`); if (invInput) invInput.checked = false; if (fieldKey === 'cs') { ['mangle-modal-cs-established', 'mangle-modal-cs-related', 'mangle-modal-cs-new', 'mangle-modal-cs-invalid', 'mangle-modal-cs-untracked'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); } if (fieldKey === 'natstate') { ['mangle-modal-nat-srcnat', 'mangle-modal-nat-dstnat'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); } } } function setMangleFieldVal(fieldKey, value, hasInvert = false) { if (fieldKey === 'ipsec' && typeof value === 'string') value = value.replace(':', ','); if (value !== undefined && value !== null && value !== '' && !(fieldKey === 'proto' && value === 'all')) { toggleMangleField(fieldKey, true); let strVal = String(value); if (hasInvert) { const inv = strVal.startsWith('!'); if (inv) strVal = strVal.slice(1); const invEl = document.getElementById(`mangle-inv-${fieldKey}`); if (invEl) invEl.checked = inv; else if (inv) strVal = '!'+strVal; } const valEl = document.getElementById(`mangle-val-${fieldKey}`); if (valEl) { if (valEl.tagName === 'SELECT' && !Array.from(valEl.options).some(o => o.value === strVal)) { const option=document.createElement('option'); option.value=strVal; option.textContent=strVal+' (saved)'; valEl.appendChild(option); } valEl.value = strVal; } } else { toggleMangleField(fieldKey, false); } } function getMangleFieldVal(fieldKey, hasInvert = false) { const fieldBox = document.getElementById(`mangle-field-${fieldKey}`); if (!fieldBox || fieldBox.classList.contains('hidden')) return ''; const valEl = document.getElementById(`mangle-val-${fieldKey}`); let v = valEl ? valEl.value.trim() : ''; if (!v) return ''; if (hasInvert) { const invEl = document.getElementById(`mangle-inv-${fieldKey}`); if (invEl && invEl.checked) { v = '!' + v.replace(/^!/, ''); } } return v; } function toggleMaximizeMangleModal() { const win = document.getElementById('mangle-modal-window'); if (!win) return; if (win.classList.contains('max-w-[720px]')) { win.classList.remove('max-w-[720px]', 'max-h-[92vh]'); win.classList.add('max-w-[96vw]', 'h-[94vh]'); } else { win.classList.remove('max-w-[96vw]', 'h-[94vh]'); win.classList.add('max-w-[720px]', 'max-h-[92vh]'); } } function toggleMangleActionFields() { const act = document.getElementById('mangle-modal-action')?.value || 'mark-connection'; // Hide all dynamic rows first ['newconnmark', 'newroutingmark', 'newpktmark', 'newmss', 'newdscp', 'newttl', 'newpriority', 'route', 'sniff', 'passthrough'].forEach(id => { const el = document.getElementById(`mangle-row-${id}`); if (el) el.classList.add('hidden'); }); const jumpBox = document.getElementById('mangle-action-jump-box'); const addrBox = document.getElementById('mangle-action-addrlist-box'); if (jumpBox) jumpBox.classList.add('hidden'); if (addrBox) addrBox.classList.add('hidden'); // Activate matching rows if (act === 'mark-connection') { document.getElementById('mangle-row-newconnmark')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'mark-routing') { document.getElementById('mangle-row-newroutingmark')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'mark-packet') { document.getElementById('mangle-row-newpktmark')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'change-mss') { document.getElementById('mangle-row-newmss')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'change-dscp') { document.getElementById('mangle-row-newdscp')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'change-ttl') { document.getElementById('mangle-row-newttl')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'set-priority') { document.getElementById('mangle-row-newpriority')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'route') { document.getElementById('mangle-row-route')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'sniff-pc' || act === 'sniff-tzsp') { document.getElementById('mangle-row-sniff')?.classList.remove('hidden'); document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'clear-df' || act === 'strip-ipv4-options') { document.getElementById('mangle-row-passthrough')?.classList.remove('hidden'); } else if (act === 'jump') { if (jumpBox) jumpBox.classList.remove('hidden'); } else if (act.includes('address-list') || act.includes('address list')) { if (addrBox) addrBox.classList.remove('hidden'); } if (act === 'log') { const logEl = document.getElementById('mangle-modal-log'); if (logEl) logEl.checked = true; toggleMangleField('logprefix', true); } } function toggleMangleLogPrefix() { const isLog = document.getElementById('mangle-modal-log')?.checked; toggleMangleField('logprefix', !!isLog); } const allMangleFieldKeys = [ 'src', 'dst', 'srclist', 'dstlist', 'srcgeo', 'dstgeo', 'proto', 'sport', 'dport', 'anyport', 'iniface', 'outiface', 'inifacelist', 'outifacelist', 'pktmark', 'connmark', 'routingmark', 'conntype', 'cs', 'natstate', 'layer7', 'content', 'connbytes', 'connrate', 'pcc', 'srcmac', 'outbridgeport', 'inbridgeport', 'inbridgeportlist', 'outbridgeportlist', 'ipsec', 'tlshost', 'ingresspriority', 'priority', 'dscp', 'tcpmss', 'pktsize', 'random', 'tcpflags', 'icmpoptions', 'ipv4options', 'ttl', 'nth', 'connlimit', 'srcaddrtype', 'dstaddrtype', 'hotspot', 'ipfragment', 'limit', 'dstlimit', 'time', 'psd', 'logprefix' ]; function openAddMangleModal() { const tos = document.getElementById('mangle-val-tos'); if (tos) tos.value = ''; switchMangleRuleTab('general'); const badge = document.getElementById('mangle-modal-badge'); if (badge) badge.innerText = 'New...'; document.getElementById('mangle-modal-id').value = ''; document.getElementById('mangle-modal-family').value = '4'; document.getElementById('mangle-modal-enabled').checked = true; document.getElementById('mangle-modal-comment').value = ''; document.getElementById('mangle-modal-chain').value = 'prerouting'; document.getElementById('mangle-modal-action').value = 'mark-connection'; if (document.getElementById('mangle-modal-newconnmark')) document.getElementById('mangle-modal-newconnmark').value = ''; if (document.getElementById('mangle-modal-newroutingmark')) document.getElementById('mangle-modal-newroutingmark').value = ''; if (document.getElementById('mangle-modal-newpktmark')) document.getElementById('mangle-modal-newpktmark').value = ''; if (document.getElementById('mangle-modal-newmss')) document.getElementById('mangle-modal-newmss').value = ''; if (document.getElementById('mangle-modal-newdscp')) document.getElementById('mangle-modal-newdscp').value = ''; if (document.getElementById('mangle-modal-newttl')) document.getElementById('mangle-modal-newttl').value = ''; if (document.getElementById('mangle-modal-newpriority')) document.getElementById('mangle-modal-newpriority').value = ''; if (document.getElementById('mangle-modal-route')) document.getElementById('mangle-modal-route').value = ''; if (document.getElementById('mangle-modal-sniff')) document.getElementById('mangle-modal-sniff').value = ''; if (document.getElementById('mangle-modal-jumptarget')) document.getElementById('mangle-modal-jumptarget').value = ''; if (document.getElementById('mangle-modal-action-addrlist')) document.getElementById('mangle-modal-action-addrlist').value = ''; if (document.getElementById('mangle-modal-action-timeout')) document.getElementById('mangle-modal-action-timeout').value = ''; if (document.getElementById('mangle-modal-passthrough')) document.getElementById('mangle-modal-passthrough').checked = true; if (document.getElementById('mangle-modal-log')) document.getElementById('mangle-modal-log').checked = false; populateInterfaceSelect('mangle-val-iniface'); populateInterfaceSelect('mangle-val-outiface'); populateAddressListSelect('mangle-val-srclist'); populateAddressListSelect('mangle-val-dstlist'); populateGeoIPSelect('mangle-val-srcgeo'); populateGeoIPSelect('mangle-val-dstgeo'); allMangleFieldKeys.forEach(k => toggleMangleField(k, false)); resetMangleCounterDisplay(); toggleMangleActionFields(); document.getElementById('mangle-modal').classList.remove('hidden'); } function openEditMangleModal(id) { id = decodeURIComponent(id); const r = (state.mangleRules || []).find(rule => rule.id === id); if (!r) return; switchMangleRuleTab('general'); const badge = document.getElementById('mangle-modal-badge'); if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`; document.getElementById('mangle-modal-id').value = r.id; document.getElementById('mangle-modal-family').value = String(r.ip_version || 4); document.getElementById('mangle-modal-enabled').checked = !r.disabled; document.getElementById('mangle-modal-comment').value = r.comment || ''; document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting'; setFirewallActionSelection('mangle', r.action || 'accept'); if (document.getElementById('mangle-modal-newconnmark')) document.getElementById('mangle-modal-newconnmark').value = r.new_connection_mark || ''; if (document.getElementById('mangle-modal-newroutingmark')) document.getElementById('mangle-modal-newroutingmark').value = r.new_routing_mark || ''; if (document.getElementById('mangle-modal-newpktmark')) document.getElementById('mangle-modal-newpktmark').value = r.new_packet_mark || ''; if (document.getElementById('mangle-modal-newmss')) document.getElementById('mangle-modal-newmss').value = r.new_mss || ''; if (document.getElementById('mangle-modal-newdscp')) document.getElementById('mangle-modal-newdscp').value = r.new_dscp != null ? r.new_dscp : ''; if (document.getElementById('mangle-modal-newttl')) document.getElementById('mangle-modal-newttl').value = r.new_ttl || ''; if (document.getElementById('mangle-modal-newpriority')) document.getElementById('mangle-modal-newpriority').value = r.new_priority || ''; if (document.getElementById('mangle-modal-route')) document.getElementById('mangle-modal-route').value = r.route || ''; if (document.getElementById('mangle-modal-sniff')) document.getElementById('mangle-modal-sniff').value = r.sniff_target || ''; if (document.getElementById('mangle-modal-jumptarget')) document.getElementById('mangle-modal-jumptarget').value = r.jump_target || ''; if (document.getElementById('mangle-modal-action-addrlist')) document.getElementById('mangle-modal-action-addrlist').value = r.address_list || ''; if (document.getElementById('mangle-modal-action-timeout')) document.getElementById('mangle-modal-action-timeout').value = r.address_list_timeout || ''; if (document.getElementById('mangle-modal-passthrough')) document.getElementById('mangle-modal-passthrough').checked = r.passthrough ?? true; if (document.getElementById('mangle-modal-log')) document.getElementById('mangle-modal-log').checked = !!r.log; populateInterfaceSelect('mangle-val-iniface', (r.in_interface || '').replace(/^!/, '')); populateInterfaceSelect('mangle-val-outiface', (r.out_interface || '').replace(/^!/, '')); populateAddressListSelect('mangle-val-srclist', (r.src_address_list || '').replace(/^!/, '')); populateAddressListSelect('mangle-val-dstlist', (r.dst_address_list || '').replace(/^!/, '')); populateGeoIPSelect('mangle-val-srcgeo', r.src_geoip || ''); populateGeoIPSelect('mangle-val-dstgeo', r.dst_geoip || ''); setMangleFieldVal('src', r.src_address, true); setMangleFieldVal('dst', r.dst_address, true); setMangleFieldVal('srclist', r.src_address_list, true); setMangleFieldVal('dstlist', r.dst_address_list, true); setMangleFieldVal('srcgeo', r.src_geoip, true); setMangleFieldVal('dstgeo', r.dst_geoip, true); if (r.protocol && r.protocol !== 'all') { setMangleFieldVal('proto', r.protocol, true); } else { toggleMangleField('proto', false); } setMangleFieldVal('sport', r.src_port, true); setMangleFieldVal('dport', r.dst_port, true); setMangleFieldVal('anyport', r.any_port, true); setMangleFieldVal('iniface', r.in_interface, true); setMangleFieldVal('outiface', r.out_interface, true); setMangleFieldVal('inifacelist', r.in_interface_list, true); setMangleFieldVal('outifacelist', r.out_interface_list, true); setMangleFieldVal('pktmark', r.packet_mark, true); setMangleFieldVal('connmark', r.connection_mark, true); setMangleFieldVal('routingmark', r.routing_mark, true); setMangleFieldVal('conntype', r.connection_type, false); // Connection State const states = r.connection_state || []; if (states.length > 0) { toggleMangleField('cs', true); ['established', 'related', 'new', 'invalid', 'untracked'].forEach(s => { const el = document.getElementById(`mangle-modal-cs-${s}`); if (el) el.checked = states.includes(s); }); } else { toggleMangleField('cs', false); } // Connection NAT State const natStates = r.connection_nat_state || []; if (natStates.length > 0) { toggleMangleField('natstate', true); ['srcnat', 'dstnat'].forEach(s => { const el = document.getElementById(`mangle-modal-nat-${s}`); if (el) el.checked = natStates.includes(s); }); } else { toggleMangleField('natstate', false); } // Advanced tab fields setMangleFieldVal('layer7', r.layer7_protocol, false); setMangleFieldVal('content', r.content, false); setMangleFieldVal('connbytes', r.connection_bytes, false); setMangleFieldVal('connrate', r.connection_rate, false); setMangleFieldVal('pcc', r.pcc, false); setMangleFieldVal('srcmac', r.src_mac_address, true); setMangleFieldVal('outbridgeport', r.out_bridge_port, false); setMangleFieldVal('inbridgeport', r.in_bridge_port, false); setMangleFieldVal('inbridgeportlist', r.in_bridge_port_list, false); setMangleFieldVal('outbridgeportlist', r.out_bridge_port_list, false); setMangleFieldVal('ipsec', r.ipsec_policy, false); setMangleFieldVal('tlshost', r.tls_host, false); setMangleFieldVal('ingresspriority', r.ingress_priority, false); setMangleFieldVal('priority', r.priority, false); setMangleFieldVal('dscp', r.dscp, false); setMangleFieldVal('tcpmss', r.tcp_mss, false); { const tosEl = document.getElementById('mangle-val-tos'); if (tosEl) tosEl.value = r.tos || ''; } setMangleFieldVal('pktsize', r.packet_size, false); setMangleFieldVal('random', r.random, false); setMangleFieldVal('tcpflags', r.tcp_flags, false); setMangleFieldVal('icmpoptions', r.icmp_options, false); setMangleFieldVal('ipv4options', r.ipv4_options, false); setMangleFieldVal('ttl', r.ttl, false); // Extra tab fields setMangleFieldVal('nth', r.nth, false); setMangleFieldVal('connlimit', r.connection_limit, false); setMangleFieldVal('srcaddrtype', r.src_address_type, false); setMangleFieldVal('dstaddrtype', r.dst_address_type, false); setMangleFieldVal('hotspot', r.hotspot, false); setMangleFieldVal('ipfragment', r.ip_fragment, false); setMangleFieldVal('limit', r.limit, false); setMangleFieldVal('dstlimit', r.dst_limit, false); setMangleFieldVal('time', r.time, false); setMangleFieldVal('psd', r.psd, false); // Action fields if (r.jump_target) { const jtEl = document.getElementById('mangle-modal-jumptarget'); if (jtEl) jtEl.value = r.jump_target; } if (r.address_list) { const alEl = document.getElementById('mangle-modal-action-addrlist'); if (alEl) alEl.value = r.address_list; } if (r.address_list_timeout) { const toEl = document.getElementById('mangle-modal-action-timeout'); if (toEl) toEl.value = r.address_list_timeout; } setMangleFieldVal('logprefix', r.log_prefix, false); // Statistics const bEl = document.getElementById('mangle-stat-bytes'); const pEl = document.getElementById('mangle-stat-packets'); if (bEl) bEl.value = formatTraffic(r.bytes || 0).replace('/s', ''); if (pEl) pEl.value = (r.packets || 0).toLocaleString(); state.mangleLastBytes = r.bytes || 0; state.mangleLastPackets = r.packets || 0; state.mangleLastTimestamp = Date.now(); state.mangleStatRateHistory = new Array(30).fill(0); state.mangleStatPktHistory = new Array(30).fill(0); renderFwSparkline('mangle-stat-rate-svg', state.mangleStatRateHistory, 'bps', 'mangle-stat-legend-rate'); renderFwSparkline('mangle-stat-pktrate-svg', state.mangleStatPktHistory, 'p/s', 'mangle-stat-legend-pktrate'); toggleMangleActionFields(); document.getElementById('mangle-modal').classList.remove('hidden'); } function closeMangleModal() { stopMangleStatPolling(); document.getElementById('mangle-modal').classList.add('hidden'); } function copyMangleRule() { document.getElementById('mangle-modal-id').value = ''; const badge = document.getElementById('mangle-modal-badge'); if (badge) badge.innerText = 'New...'; showToast('Rule Cloned', 'Ready to save as a new Mangle rule copy', 'info'); } function resetMangleCounterDisplay() { state.mangleStatRateHistory = new Array(30).fill(0); state.mangleStatPktHistory = new Array(30).fill(0); state.mangleLastBytes = 0; state.mangleLastPackets = 0; state.mangleLastTimestamp = Date.now(); const bEl = document.getElementById('mangle-stat-bytes'); const pEl = document.getElementById('mangle-stat-packets'); const rEl = document.getElementById('mangle-stat-rate'); const prEl = document.getElementById('mangle-stat-pktrate'); if (bEl) bEl.value = '0 B'; if (pEl) pEl.value = '0'; if (rEl) rEl.value = '0 bps'; if (prEl) prEl.value = '0 p/s'; renderFwSparkline('mangle-stat-rate-svg', state.mangleStatRateHistory, 'bps', 'mangle-stat-legend-rate'); renderFwSparkline('mangle-stat-pktrate-svg', state.mangleStatPktHistory, 'p/s', 'mangle-stat-legend-pktrate'); showToast('Counters Reset', 'Mangle rule counter display reset to 0', 'info'); } async function applyMangleRule(closeAfter = false) { const id = document.getElementById('mangle-modal-id').value; if (!reviewLegacyFirewallSettings('mangle', id)) return; const chain = document.getElementById('mangle-modal-chain').value; const action = document.getElementById('mangle-modal-action').value; const comment = document.getElementById('mangle-modal-comment').value.trim(); const enabled = document.getElementById('mangle-modal-enabled').checked; const src = getMangleFieldVal('src', true); const dst = getMangleFieldVal('dst', true); const srclist = getMangleFieldVal('srclist', true); const dstlist = getMangleFieldVal('dstlist', true); const srcgeo = getMangleFieldVal('srcgeo', true); const dstgeo = getMangleFieldVal('dstgeo', true); const proto = getMangleFieldVal('proto', true); const sport = getMangleFieldVal('sport', true); const dport = getMangleFieldVal('dport', true); const anyport = getMangleFieldVal('anyport', true); if ((sport || dport || anyport) && !['tcp', 'udp'].includes(proto)) { switchMangleRuleTab('general'); toggleMangleField('proto', true); showToast('Select protocol', 'Port matches require TCP or UDP without protocol inversion.', 'error'); document.getElementById('mangle-val-proto').focus(); return; } const iniface = getMangleFieldVal('iniface', true); const outiface = getMangleFieldVal('outiface', true); const inifacelist = getMangleFieldVal('inifacelist', true); const outifacelist = getMangleFieldVal('outifacelist', true); const pktmark = getMangleFieldVal('pktmark', true); const connmark = getMangleFieldVal('connmark', true); const routingmark = getMangleFieldVal('routingmark', true); const conntype = getMangleFieldVal('conntype', false); const states = []; const csBox = document.getElementById('mangle-field-cs'); if (csBox && !csBox.classList.contains('hidden')) { ['established', 'related', 'new', 'invalid', 'untracked'].forEach(s => { if (document.getElementById(`mangle-modal-cs-${s}`)?.checked) states.push(s); }); } const natStates = []; const natBox = document.getElementById('mangle-field-natstate'); if (natBox && !natBox.classList.contains('hidden')) { ['srcnat', 'dstnat'].forEach(s => { if (document.getElementById(`mangle-modal-nat-${s}`)?.checked) natStates.push(s); }); } const payload = { ip_version: Number(document.getElementById('mangle-modal-family').value), id, chain, action, protocol: proto || 'all', src_address: src, dst_address: dst, src_port: sport, dst_port: dport, any_port: anyport, in_interface: iniface, out_interface: outiface, in_interface_list: inifacelist, out_interface_list: outifacelist, packet_mark: pktmark, connection_mark: connmark, routing_mark: routingmark, connection_type: conntype, connection_state: states, connection_nat_state: natStates, src_address_list: srclist, dst_address_list: dstlist, src_geoip: srcgeo, dst_geoip: dstgeo, comment, disabled: !enabled, // Advanced tab content: getMangleFieldVal('content', false), src_mac_address: getMangleFieldVal('srcmac', true), out_bridge_port: getMangleFieldVal('outbridgeport', false), in_bridge_port: getMangleFieldVal('inbridgeport', false), ipsec_policy: getMangleFieldVal('ipsec', false), dscp: getMangleFieldVal('dscp', false), tcp_mss: getMangleFieldVal('tcpmss', false), tos: document.getElementById('mangle-val-tos')?.value || '', packet_size: getMangleFieldVal('pktsize', false), random: getMangleFieldVal('random', false), tcp_flags: getMangleFieldVal('tcpflags', false), icmp_options: getMangleFieldVal('icmpoptions', false), ttl: getMangleFieldVal('ttl', false), // Extra tab nth: getMangleFieldVal('nth', false), connection_limit: getMangleFieldVal('connlimit', false), src_address_type: getMangleFieldVal('srcaddrtype', false), dst_address_type: getMangleFieldVal('dstaddrtype', false), ip_fragment: getMangleFieldVal('ipfragment', false), limit: getMangleFieldVal('limit', false), time: getMangleFieldVal('time', false), // Action tab new_connection_mark: action === 'mark-connection' ? (document.getElementById('mangle-modal-newconnmark')?.value.trim() || '') : '', new_routing_mark: action === 'mark-routing' ? (document.getElementById('mangle-modal-newroutingmark')?.value.trim() || '') : '', new_packet_mark: action === 'mark-packet' ? (document.getElementById('mangle-modal-newpktmark')?.value.trim() || '') : '', new_mss: action === 'change-mss' ? (document.getElementById('mangle-modal-newmss')?.value.trim() || '') : '', new_dscp: action === 'change-dscp' ? parseInt(document.getElementById('mangle-modal-newdscp')?.value || '0', 10) : 0, new_ttl: action === 'change-ttl' ? (document.getElementById('mangle-modal-newttl')?.value.trim() || '') : '', new_priority: action === 'set-priority' ? (document.getElementById('mangle-modal-newpriority')?.value.trim() || '') : '', passthrough: document.getElementById('mangle-modal-passthrough')?.checked ?? true, jump_target: action === 'jump' ? (document.getElementById('mangle-modal-jumptarget')?.value || '') : '', address_list: action.includes('address') ? (document.getElementById('mangle-modal-action-addrlist')?.value || '') : '', address_list_timeout: action.includes('address') ? (document.getElementById('mangle-modal-action-timeout')?.value || '') : '', log: document.getElementById('mangle-modal-log')?.checked || false, log_prefix: getMangleFieldVal('logprefix', false) }; try { const res = await fetch(`${API_BASE}/firewall/mangle`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { const data = await res.json(); showToast('Mangle Rule Saved', 'Marking rule updated', 'success'); loadFirewall(false); if (closeAfter) { closeMangleModal(); } else { if (data.id) { document.getElementById('mangle-modal-id').value = data.id; const badge = document.getElementById('mangle-modal-badge'); if (badge) badge.innerText = `#${data.id}`; } } } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save mangle rule', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function submitSaveMangleRule(e) { if (e && e.preventDefault) e.preventDefault(); return applyMangleRule(true); } function startMangleStatPolling() { stopMangleStatPolling(); pollMangleRuleStats(); state.mangleStatInterval = setInterval(pollMangleRuleStats, 1000); } function stopMangleStatPolling() { if (state.mangleStatInterval) { clearInterval(state.mangleStatInterval); state.mangleStatInterval = null; } } async function pollMangleRuleStats() { const id = document.getElementById('mangle-modal-id')?.value; if (!id) { renderFwSparkline('mangle-stat-rate-svg', state.mangleStatRateHistory || new Array(30).fill(0), 'bps', 'mangle-stat-legend-rate'); renderFwSparkline('mangle-stat-pktrate-svg', state.mangleStatPktHistory || new Array(30).fill(0), 'p/s', 'mangle-stat-legend-pktrate'); return; } try { const res = await fetch(`${API_BASE}/firewall/mangle`); if (!res.ok) return; const rules = await res.json(); const r = rules.find(x => x.id === id); if (!r) return; const now = Date.now(); const dt = Math.max(0.2, (now - (state.mangleLastTimestamp || now)) / 1000); state.mangleLastTimestamp = now; const curBytes = r.bytes || 0; const curPackets = r.packets || 0; const deltaBytes = Math.max(0, curBytes - (state.mangleLastBytes ?? curBytes)); const deltaPkts = Math.max(0, curPackets - (state.mangleLastPackets ?? curPackets)); state.mangleLastBytes = curBytes; state.mangleLastPackets = curPackets; const rateBps = Math.round((deltaBytes * 8) / dt); const ratePkt = Math.round(deltaPkts / dt); if (!state.mangleStatRateHistory) state.mangleStatRateHistory = new Array(30).fill(0); if (!state.mangleStatPktHistory) state.mangleStatPktHistory = new Array(30).fill(0); state.mangleStatRateHistory.push(rateBps); if (state.mangleStatRateHistory.length > 30) state.mangleStatRateHistory.shift(); state.mangleStatPktHistory.push(ratePkt); if (state.mangleStatPktHistory.length > 30) state.mangleStatPktHistory.shift(); const bEl = document.getElementById('mangle-stat-bytes'); const pEl = document.getElementById('mangle-stat-packets'); const rEl = document.getElementById('mangle-stat-rate'); const prEl = document.getElementById('mangle-stat-pktrate'); if (bEl) bEl.value = formatTraffic(curBytes).replace('/s', ''); if (pEl) pEl.value = curPackets.toLocaleString(); if (rEl) rEl.value = formatBps(rateBps); if (prEl) prEl.value = `${ratePkt.toLocaleString()} p/s`; renderFwSparkline('mangle-stat-rate-svg', state.mangleStatRateHistory, 'bps', 'mangle-stat-legend-rate'); renderFwSparkline('mangle-stat-pktrate-svg', state.mangleStatPktHistory, 'p/s', 'mangle-stat-legend-pktrate'); } catch (e) { console.error('Error polling Mangle rule stats', e); } } async function moveMangleRule(id, dir) { id = decodeURIComponent(id); 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) throw new Error(await responseError(res)); await loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleMangleRuleState(id, disabled) { id = decodeURIComponent(id); 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) throw new Error(await responseError(res)); await loadFirewall(false); } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteMangleRule(id) { id = decodeURIComponent(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) throw new Error(await responseError(res)); { 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${entries.filter(e => e.dynamic).length >= 2000 ? ' (dynamic results capped at 2000)' : ''}`; // 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((raw, idx) => { const e = safeFirewallRow(raw); const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${e.disabled ? 'opacity-50' : ''}`; if (!e.dynamic) tr.setAttribute('ondblclick', `openEditAddrListModal('${e.id}')`); tr.innerHTML = ` ${idx + 1} ${e.disabled ? 'X' : '✓'} ${e.name} ${e.address} ${e.dynamic ? 'Dynamic · '+(e.timeout ? e.timeout+'s remaining' : 'no expiry') : e.comment || '-'} ${e.dynamic ? '' : ` `} `; tbody.appendChild(tr); }); populateCommonAddrListDatalist(); } 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) { id = decodeURIComponent(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) { id = decodeURIComponent(id); 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) { id = decodeURIComponent(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(); if (!res.ok) throw new Error(data.message || 'Connection tracking unavailable'); 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); const badge=document.getElementById('conns-count-badge'); if(badge) badge.textContent='Unavailable: '+err.message; } } 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' : '-'} ${['TCP','UDP','SCTP','DCCP','ICMP','ICMPV6'].includes(c.protocol) ? ` ` : ''} `; tbody.appendChild(tr); }); } async function killConnection(protocol, src, sport, dst, dport, ip_version, zone, icmp_id, icmp_type, icmp_code) { 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, ip_version, zone, icmp_id, icmp_type, icmp_code }) }); if (res.ok) { showToast('Terminated', `Killed flow ${src}:${sport} -> ${dst}:${dport}`, 'info'); loadConnections(); } else { const error = await res.json(); showToast('Error', error.message || 'Unable to terminate this flow', 'error'); } } 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'; } } }); setTimeout(initTableSorting, 50); } 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 showSystem = document.getElementById('prule-show-system')?.checked; const userRules = (state.policyRules || []).filter(pr => showSystem || (!pr.is_default && !pr.is_system)); const bRules = document.getElementById('routes-badge-rules'); if (bRules) bRules.innerText = userRules.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 getRouteProtocolBadge(proto) { const p = (proto || 'static').toLowerCase(); switch (p) { case 'bgp': return `bgp`; case 'ibgp': return `ibgp`; case 'ospf': return `ospf`; case 'rip': return `rip`; case 'static': return `static`; case 'connected': return `connected`; case 'dhcp': return `dhcp`; case 'multicast': return `igmp`; default: return `${escapeHtml(p)}`; } } function renderRoutesList() { const tableFilter = document.getElementById('routes-filter-table')?.value || ''; const typeFilter = document.getElementById('routes-filter-type')?.value || ''; const tbody = document.getElementById('routes-table-body'); const empty = document.getElementById('routes-empty'); if (!tbody) return; // 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 => { if (tableFilter && r.routing_table !== tableFilter) return false; if (typeFilter === 'ecmp' && !r.ecmp) return false; if (typeFilter === 'multicast' && !r.multicast) return false; if (typeFilter === 'static' && !r.static) return false; if (typeFilter === 'dynamic' && !r.dynamic) return false; if (typeFilter === 'connected' && !r.connect) return false; if (typeFilter === 'bgp' && (r.protocol || '').toLowerCase() !== 'bgp' && (r.protocol || '').toLowerCase() !== 'ibgp') return false; if (typeFilter === 'ospf' && (r.protocol || '').toLowerCase() !== 'ospf') return false; if (typeFilter === 'dhcp' && (r.protocol || '').toLowerCase() !== 'dhcp') return false; return true; }); const ecmpCount = (state.routes || []).filter(r => r.ecmp).length; const mcCount = (state.routes || []).filter(r => r.multicast).length; let summaryText = `${routes.length} Routes`; if (ecmpCount > 0) summaryText += ` • ${ecmpCount} ECMP`; if (mcCount > 0) summaryText += ` • ${mcCount} Multicast/IGMP`; const badgeEl = document.getElementById('routes-count-badge'); if (badgeEl) badgeEl.innerText = summaryText; if (routes.length === 0) { empty?.classList.remove('hidden'); tbody.innerHTML = ''; return; } empty?.classList.add('hidden'); const sig = `${tableFilter}|${typeFilter}|` + routes.map(r => `${r.id}:${r.active}:${r.disabled}:${r.distance}:${r.protocol}:${r.health_status}:${(r.health_rtt_ms || 0).toFixed(0)}`).join(';'); if (tbody.dataset.sig === sig) { return; // Routes table unchanged! Do not wipe DOM or flicker! } tbody.dataset.sig = sig; tbody.innerHTML = ''; 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}')`); // Routing Flags string: D=Dynamic, A=Active, C=Connect, S=Static, E=ECMP, M=Multicast, 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.ecmp) flags += 'E'; if (r.multicast) flags += 'M'; if (r.disabled) flags += 'X'; const isDefault = r.dst_address === 'default' || r.dst_address === '0.0.0.0/0'; let healthCell = `-`; if (r.check_gateway === 'internet') { if (r.health_status === 'online') { const ms = (r.health_rtt_ms || 0).toFixed(0); healthCell = ` Internet OK (${ms}ms) `; } else if (r.health_status === 'offline') { healthCell = ` Internet Down `; } else { healthCell = ` Checking... `; } } else if (r.check_gateway === 'ping') { if (r.health_status === 'online') { healthCell = ` Gateway OK (${(r.health_rtt_ms || 0).toFixed(0)}ms) `; } else if (r.health_status === 'offline') { healthCell = ` Gateway Dead `; } } const flagDescriptions = []; if (r.dynamic) flagDescriptions.push('Dynamic (D)'); if (r.active) flagDescriptions.push('Active (A)'); if (r.connect) flagDescriptions.push('Connected (C)'); if (r.static) flagDescriptions.push('Static (S)'); if (r.ecmp) flagDescriptions.push('ECMP - Equal-Cost Multi-Path (E)'); if (r.multicast) flagDescriptions.push('Multicast / IGMP Group (M)'); if (r.disabled) flagDescriptions.push('Disabled (X)'); tr.innerHTML = ` ${flags || '-'}
${escapeHtml(r.dst_address)} ${r.ecmp ? ` ⚡ ECMP ` : ''} ${r.multicast ? ` IGMP ` : ''}
${getRouteProtocolBadge(r.protocol)} ${r.gateway || 'Direct link'} ${(r.distance !== undefined && r.distance !== null) ? r.distance : 0} ${r.routing_table || 'main'} ${r.interface || '-'} ${healthCell} ${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'); const checkGw = document.getElementById('route-modal-check-gw'); if (checkGw) checkGw.value = 'internet'; const checkTarget = document.getElementById('route-modal-check-target'); if (checkTarget) checkTarget.value = '1.1.1.1'; const checkInterval = document.getElementById('route-modal-check-interval'); if (checkInterval) checkInterval.value = '5'; const checkThresh = document.getElementById('route-modal-check-threshold'); if (checkThresh) checkThresh.value = '3'; toggleRouteCheckModeUI(); 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; const checkGw = document.getElementById('route-modal-check-gw'); if (checkGw) checkGw.value = r.check_gateway || 'none'; const checkTarget = document.getElementById('route-modal-check-target'); if (checkTarget) checkTarget.value = r.check_target || '1.1.1.1'; const checkInterval = document.getElementById('route-modal-check-interval'); if (checkInterval) checkInterval.value = r.check_interval || 5; const checkThresh = document.getElementById('route-modal-check-threshold'); if (checkThresh) checkThresh.value = r.check_threshold || 3; toggleRouteCheckModeUI(); dstInput.disabled = !isStatic; gwInput.disabled = !isStatic; distInput.disabled = !isStatic; ifaceSelect.disabled = !isStatic; tableSelect.disabled = !isStatic; commentInput.disabled = !isStatic; disCheck.disabled = !isStatic; if (checkGw) checkGw.disabled = !isStatic; if (checkTarget) checkTarget.disabled = !isStatic; if (checkInterval) checkInterval.disabled = !isStatic; if (checkThresh) checkThresh.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'); } function setRouteCheckTarget(ip) { const inp = document.getElementById('route-modal-check-target'); if (inp) inp.value = ip; } function toggleRouteCheckModeUI() { const mode = document.getElementById('route-modal-check-gw')?.value || 'none'; const targetRow = document.getElementById('route-row-check-target'); const paramsRow = document.getElementById('route-row-check-params'); if (mode === 'none') { if (targetRow) targetRow.classList.add('opacity-40', 'pointer-events-none'); if (paramsRow) paramsRow.classList.add('opacity-40', 'pointer-events-none'); } else if (mode === 'ping') { if (targetRow) targetRow.classList.add('opacity-40', 'pointer-events-none'); if (paramsRow) paramsRow.classList.remove('opacity-40', 'pointer-events-none'); } else { // internet if (targetRow) targetRow.classList.remove('opacity-40', 'pointer-events-none'); if (paramsRow) paramsRow.classList.remove('opacity-40', 'pointer-events-none'); } } 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; const check_gateway = document.getElementById('route-modal-check-gw')?.value || 'none'; const check_target = document.getElementById('route-modal-check-target')?.value.trim() || '1.1.1.1'; const check_interval = parseInt(document.getElementById('route-modal-check-interval')?.value, 10) || 5; const check_threshold = parseInt(document.getElementById('route-modal-check-threshold')?.value, 10) || 3; 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, check_gateway, check_target, check_interval, check_threshold }; 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; const showSystem = document.getElementById('prule-show-system')?.checked; const filtered = (state.policyRules || []).filter(pr => showSystem || (!pr.is_default && !pr.is_system)); const countBadge = document.getElementById('rules-count-badge'); if (countBadge) countBadge.innerText = `${filtered.length} Rules`; const bRules = document.getElementById('routes-badge-rules'); if (bRules) bRules.innerText = filtered.length; if (filtered.length === 0) { empty?.classList.remove('hidden'); tbody.innerHTML = ''; return; } empty?.classList.add('hidden'); const sig = `${showSystem}|` + filtered.map(pr => `${pr.id}:${pr.priority}:${pr.src_address}:${pr.dst_address}:${pr.table}:${pr.routing_mark}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; tbody.innerHTML = ''; filtered.forEach(pr => { const isSys = pr.is_default || pr.is_system; const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${isSys ? 'opacity-70 bg-slate-950/20' : ''}`; tr.innerHTML = ` ${pr.priority} ${pr.src_address || 'all'} ${pr.dst_address || 'all'} ${pr.routing_mark || '-'} ${pr.table} ${pr.action} ${!pr.is_default ? ` ` : `Default`} `; tbody.appendChild(tr); }); } function openAddPolicyRuleModal() { const userRules = (state.policyRules || []).filter(pr => !pr.is_default && !pr.is_system); document.getElementById('prule-modal-pref').value = userRules.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; const tables = state.routingTables || []; const sig = tables.map(t => `${t.table_id}:${t.name}:${t.comment}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; 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 = `
${escapeHtml(title)}
${escapeHtml(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'); } } // ======================== AeroTUN (Custom Encrypted VPN with Adaptive LZ4) ======================== async function loadAeroTUN(populate = true) { try { const [tunnelsRes, statsRes] = await Promise.all([ fetch(`${API_BASE}/vpn/aerotun/tunnels`), fetch(`${API_BASE}/vpn/aerotun/stats`) ]); if (tunnelsRes.ok) { const data = await tunnelsRes.json(); state.aerotunTunnels = Array.isArray(data) ? data : []; } if (statsRes.ok) { state.aerotunStats = await statsRes.json(); } const countBadge = document.getElementById('aerotun-count-badge'); if (countBadge) countBadge.innerText = `${(state.aerotunTunnels || []).length} Tunnels`; const sideBadge = document.getElementById('badge-aerotun-count'); if (sideBadge) sideBadge.innerText = (state.aerotunTunnels || []).length; renderAeroTUN(); } catch (err) { console.error('Failed to load AeroTUN:', err); } } function renderAeroTUN() { const tbody = document.getElementById('aerotun-table-body'); const empty = document.getElementById('aerotun-empty'); // Update summary cards const stats = state.aerotunStats || {}; const tunnels = state.aerotunTunnels || []; const statActive = document.getElementById('aerotun-stat-active'); if (statActive) { const activeCount = tunnels.filter(t => t.connected).length; statActive.innerText = `${activeCount} / ${tunnels.length}`; } const statSaved = document.getElementById('aerotun-stat-saved'); if (statSaved) { statSaved.innerText = formatBytes(stats.total_saved_bytes || 0); } const statRatio = document.getElementById('aerotun-stat-ratio'); if (statRatio) { statRatio.innerText = stats.average_ratio || '1.00x (0% saved)'; } const statTraffic = document.getElementById('aerotun-stat-traffic'); if (statTraffic) { statTraffic.innerText = `↓ ${formatBytes(stats.total_rx_bytes || 0)} / ↑ ${formatBytes(stats.total_tx_bytes || 0)}`; } if (!tbody) return; tbody.innerHTML = ''; 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' : ''}`; const modeBadge = t.mode === 'server' ? `Server` : `Client`; const endpointInfo = t.mode === 'server' ? `Port :${t.listen_port || 51830}` : `${escapeHtml(t.remote_endpoint || '(not set)')}`; const compBadge = t.compression ? `${t.compression_ratio || 'LZ4'}` : `Off`; const activePeer = t.connected && t.active_peer_endpoint ? `● ${escapeHtml(t.active_peer_endpoint)}` : `${t.last_error ? 'Failed: ' + escapeHtml(t.last_error) : (!t.running ? (t.disabled ? 'Saved / disabled' : 'Stopped') : (t.mode === 'server' ? 'Waiting for peer...' : 'Connecting...'))}`; const rawRx = formatBytes(t.rx_bytes || 0); const rawTx = formatBytes(t.tx_bytes || 0); const wireRx = formatBytes(t.wire_bytes_in || 0); const wireTx = formatBytes(t.wire_bytes_out || 0); tr.innerHTML = ` ${renderMikroTikFlag(t.connected, t.disabled)} ${escapeHtml(t.name)} ${t.default_route ? 'DEF' : ''} v${t.protocol_version || 2} ${modeBadge} ${endpointInfo} ${escapeHtml(t.local_address || '-')} ${compBadge} ${activePeer} ↓ ${rawRx} ↑ ${rawTx} ↓ ${wireRx} ↑ ${wireTx} ${escapeHtml(t.comment || '-')} ${t.running ? `` : `` } `; tbody.appendChild(tr); }); } let aeroModalGeneration = 0; let aeroIdentityDraft = ''; let aeroSaving = false; let aeroPSKRequest = 0; function discardAeroIdentityDraft(id) { if (id) fetch(`${API_BASE}/vpn/aerotun/identity-drafts/${encodeURIComponent(id)}`, {method:'DELETE'}).catch(() => {}); } async function generateAeroTUNIdentity() { const generation = aeroModalGeneration; const retry = document.getElementById('aerotun-retry-identity'); if (aeroIdentityDraft) { discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = ''; } retry.classList.add('hidden'); document.getElementById('aerotun-modal-public').value = ''; try { const res = await fetch(`${API_BASE}/vpn/aerotun/identity-drafts`, {method:'POST'}); if (!res.ok) throw new Error(await responseError(res)); const draft = await res.json(); if (generation !== aeroModalGeneration) { discardAeroIdentityDraft(draft.draft_id); return; } aeroIdentityDraft = draft.draft_id; document.getElementById('aerotun-modal-public').value = draft.public_key; updateAeroTUNSetupHint(); } catch (err) { if (generation !== aeroModalGeneration) return; retry.classList.remove('hidden'); updateAeroTUNSetupHint(); showToast('Identity Generation Failed', err.message, 'error'); } } function onAeroTUNModeChange() { const mode = document.getElementById('aerotun-modal-mode').value; const endpointInput = document.getElementById('aerotun-modal-endpoint'); const portInput = document.getElementById('aerotun-modal-port'); if (mode === 'server') { if (!portInput.value) portInput.value = '51830'; endpointInput.placeholder = 'Optional / learned roaming peer'; } else { endpointInput.placeholder = 'vpn.example.com:51830 (required)'; if (!portInput.value) portInput.value = ''; } } function onAeroTUNPeerChange() { const peer = document.getElementById('aerotun-modal-peer'); const allowed = document.getElementById('aerotun-modal-allowed'); const previous = peer.dataset.allowedDefault || ''; const host = peer.value.trim().split('/')[0]; const next = host ? `${host}/${host.includes(':') ? 128 : 32}` : ''; if (allowed.value.trim() === previous) allowed.value = next; peer.dataset.allowedDefault = next; } async function generateAeroTUNKey(automatic = false) { const generation = aeroModalGeneration, request = ++aeroPSKRequest; const input = document.getElementById('aerotun-modal-psk'), previous = input.value; try { const res = await fetch(`${API_BASE}/vpn/aerotun/genkey`); if (!res.ok) throw new Error(await responseError(res)); const data = await res.json(); if (generation !== aeroModalGeneration || request !== aeroPSKRequest || (automatic && input.value !== previous)) return; input.value = data.key; if (!automatic) showToast('Key Generated', 'Share this PSK securely with the peer before enabling', 'success'); } catch (err) { if (generation === aeroModalGeneration && request === aeroPSKRequest) showToast('Key Generation Failed', err.message, 'error'); } } function updateAeroTUNSetupHint() { const version = document.getElementById('aerotun-modal-protocol').value; const key = document.getElementById('aerotun-modal-public').value; const peer = document.getElementById('aerotun-modal-peerkey').value.trim(); const hint = document.getElementById('aerotun-setup-hint'); document.getElementById('aerotun-v3-fields').classList.toggle('hidden', version !== '3'); if (!hint) return; hint.innerText = version !== '3' ? 'Legacy v2 uses a shared PSK. Both endpoints must use the same protocol.' : !key ? (document.getElementById('aerotun-modal-id').value ? 'Save this existing tunnel disabled to create its v3 identity, then exchange public keys.' : 'Generating the local identity. You can fill in the other settings while you wait.') : !peer ? 'Copy the local public key to the peer and paste its public key here. Use the same PSK on both endpoints; save disabled if the peer key is not ready.' : 'Check reciprocal addresses, allowed peer source prefixes and the shared PSK before enabling. Allowed prefixes do not create LAN routes.'; } async function copyAeroTUNPublicKey() { const input = document.getElementById('aerotun-modal-public'); if (!input.value) { showToast('Key Not Ready', 'Wait for the local identity or retry key generation.', 'info'); return; } try { await navigator.clipboard.writeText(input.value); showToast('Public Key Copied', 'Paste it into the other endpoint’s pinned peer key', 'success'); } catch { input.focus(); input.select(); showToast('Copy Public Key', 'Key selected; press Ctrl+C to copy', 'info'); } } async function copyAeroTUNPSK() { const input = document.getElementById('aerotun-modal-psk'); if (!input.value) { showToast('PSK Not Available', 'Generate or enter a PSK to copy it. Existing keys are not displayed.', 'info'); return; } try { await navigator.clipboard.writeText(input.value); showToast('PSK Copied', 'Share it securely with the peer.', 'success'); } catch { input.type = 'text'; input.focus(); input.select(); showToast('Copy PSK', 'Key selected; press Ctrl+C to copy.', 'info'); } } function openAddAeroTUNModal() { if (aeroSaving) return; aeroModalGeneration++; discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = ''; const tunnels = state.aerotunTunnels || []; const usedNames = new Set(tunnels.map(t => t.name)); const usedPorts = new Set(tunnels.map(t => Number(t.listen_port))); let index = 0; while (index < 256 && (usedNames.has(`aerotun${index}`) || usedPorts.has(51830 + index) || tunnels.some(t => (t.local_address || '').startsWith(`10.99.${index}.`)))) index++; if (index >= 256) index = 0; document.getElementById('aerotun-modal-title').innerText = 'Add AeroTUN Interface'; document.getElementById('aerotun-modal-id').value = ''; document.getElementById('aerotun-modal-protocol').value='3'; document.getElementById('aerotun-modal-public').value=''; document.getElementById('aerotun-modal-peerkey').value=''; document.getElementById('aerotun-modal-allowed').value=`10.99.${index}.2/32`; document.getElementById('aerotun-modal-name').value = `aerotun${index}`; document.getElementById('aerotun-modal-mode').value = 'server'; document.getElementById('aerotun-modal-port').value = 51830 + index; document.getElementById('aerotun-modal-endpoint').value = ''; document.getElementById('aerotun-modal-local').value = `10.99.${index}.1/24`; document.getElementById('aerotun-modal-peer').value = `10.99.${index}.2/24`; document.getElementById('aerotun-modal-peer').dataset.allowedDefault = `10.99.${index}.2/32`; document.getElementById('aerotun-modal-psk').value = ''; document.getElementById('aerotun-modal-mtu').value = '1420'; document.getElementById('aerotun-modal-comment').value = ''; document.getElementById('aerotun-modal-compression').checked = false; document.getElementById('aerotun-modal-defroute').checked = false; document.getElementById('aerotun-modal-disabled').checked = true; document.getElementById('aerotun-modal-psk').type = 'password'; document.getElementById('aerotun-modal-psk').placeholder = 'Generate or enter the shared key'; onAeroTUNModeChange(); updateAeroTUNSetupHint(); document.getElementById('aerotun-modal').classList.remove('hidden'); generateAeroTUNKey(true); generateAeroTUNIdentity(); } function openEditAeroTUNModal(id) { if (aeroSaving) return; const t = (state.aerotunTunnels || []).find(tun => tun.id === id); if (!t) return; aeroModalGeneration++; discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = ''; document.getElementById('aerotun-retry-identity').classList.add('hidden'); document.getElementById('aerotun-modal-title').innerText = `Edit AeroTUN Interface (${t.name})`; document.getElementById('aerotun-modal-id').value = t.id; document.getElementById('aerotun-modal-protocol').value=String(t.protocol_version || 2); document.getElementById('aerotun-modal-public').value=t.public_key || ''; document.getElementById('aerotun-modal-peerkey').value=t.peer_public_key || ''; document.getElementById('aerotun-modal-allowed').value=(t.allowed_ips || []).join(', '); document.getElementById('aerotun-modal-name').value = t.name; document.getElementById('aerotun-modal-mode').value = t.mode || 'server'; document.getElementById('aerotun-modal-port').value = t.listen_port || ''; document.getElementById('aerotun-modal-endpoint').value = t.remote_endpoint || ''; document.getElementById('aerotun-modal-local').value = t.local_address || ''; document.getElementById('aerotun-modal-peer').value = t.peer_address || ''; const peerHost = (t.peer_address || '').split('/')[0]; document.getElementById('aerotun-modal-peer').dataset.allowedDefault = peerHost ? `${peerHost}/${peerHost.includes(':') ? 128 : 32}` : ''; document.getElementById('aerotun-modal-psk').value = ''; document.getElementById('aerotun-modal-psk').type = 'password'; document.getElementById('aerotun-modal-psk').placeholder = t.has_psk ? 'Leave blank to keep the existing key' : 'New random 256-bit PSK'; document.getElementById('aerotun-modal-mtu').value = t.mtu || 1420; document.getElementById('aerotun-modal-comment').value = t.comment || ''; document.getElementById('aerotun-modal-compression').checked = !!t.compression; document.getElementById('aerotun-modal-defroute').checked = !!t.default_route; document.getElementById('aerotun-modal-disabled').checked = !!t.disabled; onAeroTUNModeChange(); updateAeroTUNSetupHint(); document.getElementById('aerotun-modal').classList.remove('hidden'); } function closeAeroTUNModal() { if (aeroSaving) return; aeroModalGeneration++; discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = ''; document.getElementById('aerotun-modal-psk').value = ''; document.getElementById('aerotun-modal').classList.add('hidden'); } async function submitSaveAeroTUN(e) { e.preventDefault(); if (aeroSaving) return; const id = document.getElementById('aerotun-modal-id').value; const name = document.getElementById('aerotun-modal-name').value.trim(); const mode = document.getElementById('aerotun-modal-mode').value; const port = parseInt(document.getElementById('aerotun-modal-port').value) || 0; const endpoint = document.getElementById('aerotun-modal-endpoint').value.trim(); const local = document.getElementById('aerotun-modal-local').value.trim(); const peer = document.getElementById('aerotun-modal-peer').value.trim(); const psk = document.getElementById('aerotun-modal-psk').value.trim(); const mtuValue = document.getElementById('aerotun-modal-mtu').value; const mtu = mtuValue === '' ? 1420 : Number(mtuValue); const comment = document.getElementById('aerotun-modal-comment').value.trim(); const compression = document.getElementById('aerotun-modal-compression').checked; const defaultRoute = document.getElementById('aerotun-modal-defroute').checked; const disabled = document.getElementById('aerotun-modal-disabled').checked; if (mode === 'client' && !endpoint) { showToast('Validation Error', 'Remote endpoint (host:port) is required in client mode', 'error'); return; } if (defaultRoute && (mode !== 'client' || !endpoint)) { showToast('Validation Error', 'Default route requires client mode and a fixed remote endpoint.', 'error'); return; } if (!Number.isInteger(mtu) || mtu < 576 || mtu > 9000) { showToast('Validation Error', 'MTU must be between 576 and 9000.', 'error'); return; } const version = parseInt(document.getElementById('aerotun-modal-protocol').value, 10); if (version === 3 && !id && !aeroIdentityDraft) { showToast('Local Key Not Ready', 'Wait for the local identity or retry key generation before saving.', 'error'); return; } if (version === 3 && !document.getElementById('aerotun-modal-allowed').value.trim()) { showToast('Allowed Prefix Required', 'Enter at least one allowed peer source prefix, such as the peer tunnel IP /32.', 'error'); document.getElementById('aerotun-modal-allowed').focus(); return; } if (version === 3 && !disabled && !document.getElementById('aerotun-modal-peerkey').value.trim()) { showToast('Peer Key Required', 'Enter the peer public key, or save this tunnel disabled until it is available.', 'error'); return; } if (!psk && !id) { showToast('Validation Error', 'Pre-shared key (PSK) is required', 'error'); return; } aeroSaving = true; const submit = document.querySelector('#aerotun-modal button[type="submit"]'); if (submit) submit.disabled = true; try { const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ protocol_version:parseInt(document.getElementById('aerotun-modal-protocol').value,10), identity_draft_id: version === 3 && !id ? aeroIdentityDraft : '', peer_public_key:document.getElementById('aerotun-modal-peerkey').value.trim(), allowed_ips:document.getElementById('aerotun-modal-allowed').value.split(',').map(s=>s.trim()).filter(Boolean), id, name, mode, listen_port: port, remote_endpoint: endpoint, local_address: local, peer_address: peer, psk, mtu, compression, default_route: defaultRoute, comment, disabled }) }); if (res.ok) { await res.json(); if (version !== 3) discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = ''; aeroSaving = false; closeAeroTUNModal(); await loadAeroTUN(false); showToast('AeroTUN Saved', disabled ? 'Configuration saved; tunnel is disabled' : 'Configuration saved; waiting for authenticated peer traffic', 'success'); fetchData(false); } else { const err = await responseError(res); if (err.includes('configuration saved but tunnel failed to start')) { aeroIdentityDraft = ''; aeroSaving = false; closeAeroTUNModal(); await loadAeroTUN(false); showToast('Saved, but not started', err, 'error'); } else showToast('Error Saving Tunnel', err, 'error'); } } catch (err) { showToast('Network Error', err.message, 'error'); } finally { aeroSaving = false; if (submit) submit.disabled = false; } } async function startAeroTUNTunnel(id) { try { const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}/start`, { method: 'POST' }); if (res.ok) { showToast('AeroTUN Started', 'Interface started; connection is confirmed by authenticated peer traffic', 'info'); loadAeroTUN(false); fetchData(false); } else { const err = await res.text(); showToast('Failed to Start', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function stopAeroTUNTunnel(id) { try { const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}/stop`, { method: 'POST' }); if (res.ok) { showToast('AeroTUN Stopped', 'Tunnel stopped', 'info'); loadAeroTUN(false); fetchData(false); } else { const err = await res.text(); showToast('Failed to Stop', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleAeroTUNState(id, disabled) { try { const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast('State Updated', disabled ? 'Tunnel disabled' : 'Tunnel enabled', 'info'); loadAeroTUN(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteAeroTUN(id) { if (!confirm('Are you sure you want to delete this AeroTUN tunnel?')) return; try { const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'AeroTUN tunnel removed', 'success'); loadAeroTUN(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.activeOspfTab = 'instances'; state.bgpInstance = null; state.bgpPeers = []; state.bgpNets = []; state.bgpRoutes = []; state.ospfInstance = null; state.ospfInstances = []; state.ospfAreas = []; state.ospfAreaRanges = []; state.ospfTemplates = []; state.ospfLiveInterfaces = []; state.ospfStaticNbrs = []; state.ospfNeighbors = []; state.ospfLSA = []; state.ospfRoutes = []; state.selectedOspfRow = { instances: null, areas: null, 'area-ranges': null, templates: null, interfaces: null, 'static-nbrs': null, neighbors: null, lsa: null, }; state.ospfSort = { instances: { col: 'name', asc: true }, areas: { col: 'name', asc: true }, ranges: { col: 'area', asc: true }, templates: { col: 'interfaces', asc: true }, live_ifaces: { col: 'interface', asc: true }, snbrs: { col: 'address', asc: true }, neighbors: { col: 'address', asc: true }, lsa: { col: 'instance', asc: true }, }; 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 switchOspfTab(tabName) { state.activeOspfTab = tabName; const tabs = ['instances', 'areas', 'area-ranges', 'templates', 'interfaces', 'static-nbrs', 'neighbors', 'lsa']; tabs.forEach(t => { const pane = document.getElementById(`ospf-pane-${t}`); const btn = document.getElementById(`ospf-tab-btn-${t}`); if (pane) pane.classList.toggle('hidden', t !== tabName); if (btn) { if (t === tabName) { btn.className = 'px-3 py-2 text-xs font-semibold border-b-2 border-sky-500 text-white transition flex items-center gap-1.5 whitespace-nowrap'; } else { btn.className = 'px-3 py-2 text-xs font-semibold border-b-2 border-transparent text-slate-400 hover:text-white transition flex items-center gap-1.5 whitespace-nowrap'; } } }); } function switchOspfSubTab(tab) { // Legacy alias if (tab === 'ospf-ifaces') switchOspfTab('templates'); else if (tab === 'ospf-areas') switchOspfTab('areas'); else if (tab === 'ospf-nbrs') switchOspfTab('neighbors'); else switchOspfTab('instances'); } 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'); switchOspfTab('instances'); } } 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, ospfInsts, ospfAreas, ospfRanges, ospfTemplates, ospfLiveIfaces, ospfStaticNbrs, ospfNbrs, ospfLsa, filters, selectRules, commLists, asLists ] = 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/instances`), fetchSafeJson(`${API_BASE}/routing/ospf/areas`), fetchSafeJson(`${API_BASE}/routing/ospf/area-ranges`), fetchSafeJson(`${API_BASE}/routing/ospf/templates`), fetchSafeJson(`${API_BASE}/routing/ospf/interfaces-live`), fetchSafeJson(`${API_BASE}/routing/ospf/static-neighbors`), fetchSafeJson(`${API_BASE}/routing/ospf/neighbors`), fetchSafeJson(`${API_BASE}/routing/ospf/lsa`), fetchSafeJson(`${API_BASE}/routing/filters`), fetchSafeJson(`${API_BASE}/routing/filters/select-rules`), fetchSafeJson(`${API_BASE}/routing/filters/community-lists`), fetchSafeJson(`${API_BASE}/routing/filters/as-lists`) ]); 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 (ospfInsts) state.ospfInstances = ospfInsts; if (ospfAreas) state.ospfAreas = ospfAreas; if (ospfRanges) state.ospfAreaRanges = ospfRanges; if (ospfTemplates) state.ospfTemplates = ospfTemplates; if (ospfLiveIfaces) state.ospfLiveInterfaces = ospfLiveIfaces; if (ospfStaticNbrs) state.ospfStaticNbrs = ospfStaticNbrs; if (ospfNbrs) state.ospfNeighbors = ospfNbrs; if (ospfLsa) state.ospfLSA = ospfLsa; if (filters) state.routingFilters = filters; if (selectRules) state.routingSelectRules = selectRules; if (commLists) state.routingCommunityLists = commLists; if (asLists) state.routingASLists = asLists; 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 { renderOspfInstances(); } catch(e) { console.error('renderOspfInstances error:', e); } try { renderOspfAreas(); } catch(e) { console.error('renderOspfAreas error:', e); } try { renderOspfAreaRanges(); } catch(e) { console.error('renderOspfAreaRanges error:', e); } try { renderOspfTemplates(); } catch(e) { console.error('renderOspfTemplates error:', e); } try { renderOspfLiveInterfaces(); } catch(e) { console.error('renderOspfLiveInterfaces error:', e); } try { renderOspfStaticNbrs(); } catch(e) { console.error('renderOspfStaticNbrs error:', e); } try { renderOspfNeighbors(); } catch(e) { console.error('renderOspfNeighbors error:', e); } try { renderOspfLSA(); } catch(e) { console.error('renderOspfLSA 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'); } } async function loadOspfAll() { await loadDynamicRouting(true); } function renderBgpStatusPill() { const pills = document.querySelectorAll('.bgp-live-status-pill'); pills.forEach(pill => { const inst = state.bgpInstance; if (!inst || !inst.enabled) { pill.className = 'bgp-live-status-pill 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 = 'bgp-live-status-pill 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 = 'bgp-live-status-pill 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'); const sig = peers.map(p => `${p.name}:${p.state}:${p.uptime}:${p.prefix_received}:${p.prefix_sent}:${p.disabled}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; 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}
${p.local_as ? `
Local: AS ${p.local_as}
` : `
Global 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'); const sig = nets.map(n => `${n.network}:${n.disabled}:${n.comment}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; 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'); const sig = routes.map(r => `${r.prefix}:${r.next_hop}:${r.metric}:${r.path}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; 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(''); } // ========================================================================= // DEBTIK v7 OSPF TABLE RENDERING & SORTING // ========================================================================= function sortOspfTable(tab, col) { if (!state.ospfSort) state.ospfSort = {}; if (!state.ospfSort[tab]) state.ospfSort[tab] = { col: col, asc: true }; if (state.ospfSort[tab].col === col) { state.ospfSort[tab].asc = !state.ospfSort[tab].asc; } else { state.ospfSort[tab].col = col; state.ospfSort[tab].asc = true; } const asc = state.ospfSort[tab].asc; let list = []; if (tab === 'instances') list = state.ospfInstances || []; else if (tab === 'areas') list = state.ospfAreas || []; else if (tab === 'ranges') list = state.ospfAreaRanges || []; else if (tab === 'templates') list = state.ospfTemplates || []; else if (tab === 'live_ifaces') list = state.ospfLiveInterfaces || []; else if (tab === 'snbrs') list = state.ospfStaticNbrs || []; else if (tab === 'neighbors') list = state.ospfNeighbors || []; else if (tab === 'lsa') list = state.ospfLSA || []; list.sort((a, b) => { let valA = a[col] ?? ''; let valB = b[col] ?? ''; if (typeof valA === 'number' && typeof valB === 'number') { return asc ? valA - valB : valB - valA; } return asc ? String(valA).localeCompare(String(valB)) : String(valB).localeCompare(String(valA)); }); if (tab === 'instances') renderOspfInstances(); else if (tab === 'areas') renderOspfAreas(); else if (tab === 'ranges') renderOspfAreaRanges(); else if (tab === 'templates') renderOspfTemplates(); else if (tab === 'live_ifaces') renderOspfLiveInterfaces(); else if (tab === 'snbrs') renderOspfStaticNbrs(); else if (tab === 'neighbors') renderOspfNeighbors(); else if (tab === 'lsa') renderOspfLSA(); } function selectOspfRow(tab, id) { if (!state.selectedOspfRow) state.selectedOspfRow = {}; state.selectedOspfRow[tab] = id; if (tab === 'instances') { renderOspfInstances(); const btnEn = document.getElementById('btn-ospf-inst-enable'); const btnDis = document.getElementById('btn-ospf-inst-disable'); const btnRem = document.getElementById('btn-ospf-inst-remove'); const btnCom = document.getElementById('btn-ospf-inst-comment'); if (btnEn) btnEn.disabled = false; if (btnDis) btnDis.disabled = false; if (btnRem) btnRem.disabled = false; if (btnCom) btnCom.disabled = false; } else if (tab === 'areas') { renderOspfAreas(); const btnEn = document.getElementById('btn-ospf-area-enable'); const btnDis = document.getElementById('btn-ospf-area-disable'); const btnRem = document.getElementById('btn-ospf-area-remove'); const btnCom = document.getElementById('btn-ospf-area-comment'); if (btnEn) btnEn.disabled = false; if (btnDis) btnDis.disabled = false; if (btnRem) btnRem.disabled = false; if (btnCom) btnCom.disabled = false; } else if (tab === 'ranges') { renderOspfAreaRanges(); const btnEn = document.getElementById('btn-ospf-range-enable'); const btnDis = document.getElementById('btn-ospf-range-disable'); const btnRem = document.getElementById('btn-ospf-range-remove'); const btnCom = document.getElementById('btn-ospf-range-comment'); if (btnEn) btnEn.disabled = false; if (btnDis) btnDis.disabled = false; if (btnRem) btnRem.disabled = false; if (btnCom) btnCom.disabled = false; } else if (tab === 'templates') { renderOspfTemplates(); const btnEn = document.getElementById('btn-ospf-tmpl-enable'); const btnDis = document.getElementById('btn-ospf-tmpl-disable'); const btnRem = document.getElementById('btn-ospf-tmpl-remove'); const btnCom = document.getElementById('btn-ospf-tmpl-comment'); if (btnEn) btnEn.disabled = false; if (btnDis) btnDis.disabled = false; if (btnRem) btnRem.disabled = false; if (btnCom) btnCom.disabled = false; } else if (tab === 'snbrs') { renderOspfStaticNbrs(); const btnEn = document.getElementById('btn-ospf-snbr-enable'); const btnDis = document.getElementById('btn-ospf-snbr-disable'); const btnRem = document.getElementById('btn-ospf-snbr-remove'); const btnCom = document.getElementById('btn-ospf-snbr-comment'); if (btnEn) btnEn.disabled = false; if (btnDis) btnDis.disabled = false; if (btnRem) btnRem.disabled = false; if (btnCom) btnCom.disabled = false; } } // TAB 1: INSTANCES function renderOspfInstances() { const tbody = document.getElementById('ospf-instances-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-instances-footer-count'); const filter = (document.getElementById('filter-ospf-instances')?.value || '').toLowerCase(); let list = state.ospfInstances || []; if (filter) { list = list.filter(i => (i.name || '').toLowerCase().includes(filter) || (i.router_id || '').toLowerCase().includes(filter) || (i.comment || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; const selId = state.selectedOspfRow?.instances; tbody.innerHTML = list.map((inst, idx) => { const isSel = inst.id === selId; const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-sky-400' : 'hover:bg-[#1a2636] text-slate-300'; const flagD = inst.disabled ? 'X' : ''; return ` ${idx} ${flagD || '🔀'} ${escapeHtml(inst.comment || '')} ↳ ${escapeHtml(inst.name || 'Default')} ${inst.version || 2} ${escapeHtml(inst.vrf || 'main')} ${escapeHtml(inst.router_id || '192.168.131.56')} `; }).join(''); } // TAB 2: AREAS function renderOspfAreas() { const tbody = document.getElementById('ospf-areas-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-areas-footer-count'); const filter = (document.getElementById('filter-ospf-areas')?.value || '').toLowerCase(); let list = state.ospfAreas || []; if (filter) { list = list.filter(a => (a.name || '').toLowerCase().includes(filter) || (a.area_id || '').toLowerCase().includes(filter) || (a.comment || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; const selId = state.selectedOspfRow?.areas; tbody.innerHTML = list.map((a, idx) => { const isSel = a.id === selId; const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-sky-400' : 'hover:bg-[#1a2636] text-slate-300'; const flagD = a.disabled ? 'X' : ''; return ` ${idx} ${flagD || '🌐'} ${escapeHtml(a.comment || '')} ↳ ${escapeHtml(a.name || 'Default')} ${escapeHtml(a.instance || 'Default')} ${escapeHtml(a.area_id || '0.0.0.0')} ${escapeHtml(a.area_type || 'default')} `; }).join(''); } // TAB 3: AREA RANGES function renderOspfAreaRanges() { const tbody = document.getElementById('ospf-ranges-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-ranges-footer-count'); const filter = (document.getElementById('filter-ospf-area-ranges')?.value || '').toLowerCase(); let list = state.ospfAreaRanges || []; if (filter) { list = list.filter(r => (r.prefix || '').toLowerCase().includes(filter) || (r.area || '').toLowerCase().includes(filter) || (r.comment || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; const selId = state.selectedOspfRow?.['area-ranges']; tbody.innerHTML = list.map((r, idx) => { const isSel = r.id === selId; const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-sky-400' : 'hover:bg-[#1a2636] text-slate-300'; const flagD = r.disabled ? 'X' : ''; return ` ${idx} ${flagD || '🏷'} ${escapeHtml(r.comment || '')} ${escapeHtml(r.area || 'Default')} ${escapeHtml(r.prefix || '')} ${r.cost || 1} ${r.advertise !== false ? 'yes' : 'no'} `; }).join(''); } // TAB 4: INTERFACE TEMPLATES function renderOspfTemplates() { const tbody = document.getElementById('ospf-templates-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-templates-footer-count'); const filter = (document.getElementById('filter-ospf-templates')?.value || '').toLowerCase(); let list = state.ospfTemplates || []; if (filter) { list = list.filter(t => (t.interfaces || t.interface || '').toLowerCase().includes(filter) || (t.networks || t.network || '').toLowerCase().includes(filter) || (t.comment || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; const selId = state.selectedOspfRow?.templates; tbody.innerHTML = list.map((t, idx) => { const isSel = t.id === selId; const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-sky-400' : 'hover:bg-[#1a2636] text-slate-300'; const ifaces = t.interfaces || t.interface || 'all'; const nets = t.networks || t.network || ''; const flagD = t.disabled ? 'X' : ''; return ` ${idx} ${flagD || '🔀'} ${escapeHtml(t.comment || '')} ↳ ${escapeHtml(ifaces)} ${escapeHtml(t.area || 'Default')} ${escapeHtml(nets)} ${escapeHtml(t.network_type || 'broadcast')} ${t.cost || 1} ${t.priority || 128} ${escapeHtml(t.auth || t.auth_type || '')} `; }).join(''); } // TAB 5: LIVE INTERFACES function renderOspfLiveInterfaces() { const tbody = document.getElementById('ospf-live-interfaces-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-live-interfaces-footer-count'); const filter = (document.getElementById('filter-ospf-interfaces')?.value || '').toLowerCase(); let list = state.ospfLiveInterfaces || []; if (filter) { list = list.filter(i => (i.interface || '').toLowerCase().includes(filter) || (i.address || '').toLowerCase().includes(filter) || (i.state || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; tbody.innerHTML = list.map(i => ` ${escapeHtml(i.flags || 'D')} ${escapeHtml(i.interface || '')} ${escapeHtml(i.address || '')} ${escapeHtml(i.area || 'Default')} ${i.instance_id || 0} ${escapeHtml(i.state || 'waiting')} ${i.cost || 1} ${i.priority || 128} `).join(''); } // TAB 6: STATIC NEIGHBORS function renderOspfStaticNbrs() { const tbody = document.getElementById('ospf-snbrs-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-snbrs-footer-count'); const filter = (document.getElementById('filter-ospf-static-nbrs')?.value || '').toLowerCase(); let list = state.ospfStaticNbrs || []; if (filter) { list = list.filter(n => (n.address || '').toLowerCase().includes(filter) || (n.area || '').toLowerCase().includes(filter) || (n.comment || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; const selId = state.selectedOspfRow?.['static-nbrs']; tbody.innerHTML = list.map((n, idx) => { const isSel = n.id === selId; const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-sky-400' : 'hover:bg-[#1a2636] text-slate-300'; const flagD = n.disabled ? 'X' : ''; return ` ${idx} ${flagD || '📡'} ${escapeHtml(n.comment || '')} ${escapeHtml(n.address || '')} ${escapeHtml(n.area || 'Default')} ${n.poll_interval || 120}s ${n.cost || 1} `; }).join(''); } // TAB 7: LIVE NEIGHBORS function renderOspfNeighbors() { const tbody = document.getElementById('ospf-neighbors-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-neighbors-footer-count'); const filter = (document.getElementById('filter-ospf-neighbors')?.value || '').toLowerCase(); let list = state.ospfNeighbors || []; if (filter) { list = list.filter(n => (n.address || '').toLowerCase().includes(filter) || (n.state || '').toLowerCase().includes(filter) || (n.instance || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; tbody.innerHTML = list.map(n => ` ${escapeHtml(n.flags || 'D')} ${escapeHtml(n.comment || '')} ↳ ${escapeHtml(n.instance || 'Default')} ${escapeHtml(n.area || 'Default')} ${escapeHtml(n.address || '')} ${escapeHtml(n.state || 'Full')} ${n.state_changes ?? 6} `).join(''); } // TAB 8: LINK-STATE DATABASE (LSA) function renderOspfLSA() { const tbody = document.getElementById('ospf-lsa-table-body'); if (!tbody) return; const countEl = document.getElementById('ospf-lsa-footer-count'); const filter = (document.getElementById('filter-ospf-lsa')?.value || '').toLowerCase(); let list = state.ospfLSA || []; if (filter) { list = list.filter(l => (l.type || '').toLowerCase().includes(filter) || (l.originator || '').toLowerCase().includes(filter) || (l.id || '').toLowerCase().includes(filter)); } if (countEl) countEl.innerText = list.length; tbody.innerHTML = list.map(l => ` ${escapeHtml(l.flags || 'D')} ↳ ${escapeHtml(l.instance || 'Default')} ${escapeHtml(l.area || 'Default')} ${escapeHtml(l.type || 'router')} ${escapeHtml(l.originator || '')} ${escapeHtml(l.id || '')} ${escapeHtml(l.link || '')} ${l.link_index ?? 0} ${escapeHtml(l.sequence || '')} ${l.age ?? 0} `).join(''); } function renderOspfStatusPill() { // Maintained for backward compatibility const pill = document.getElementById('ospf-live-status-pill'); if (!pill) return; 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'; } function renderOspfInterfaces() { renderOspfTemplates(); } function renderOspfRoutes() { // Routes retained in background } // 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'); } } // ----------------- BGP Peer Connection Modal (DebTik v7) ----------------- function switchBgpPeerModalTab(tab) { const tabs = ['general', 'extra', 'attributes', 'filter']; tabs.forEach(t => { const pane = document.getElementById(`bgp-peer-tab-${t}`); const btn = document.getElementById(`bgp-peer-tab-btn-${t}`); if (t === tab) { if (pane) pane.classList.remove('hidden'); if (btn) btn.className = 'px-4 py-2 text-xs font-bold border-b-2 border-cyan-500 text-white transition'; } else { if (pane) pane.classList.add('hidden'); if (btn) btn.className = 'px-4 py-2 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition'; } }); } function addOutputNetworkRow(val = '') { const container = document.getElementById('modal-bgp-output-networks-container'); if (!container) return; const row = document.createElement('div'); row.className = 'flex items-center gap-2 output-net-row'; row.innerHTML = ` `; container.appendChild(row); } function parseBgpTime(val) { if (!val) return 0; val = String(val).trim(); if (val.includes(':')) { const parts = val.split(':').map(x => parseInt(x, 10) || 0); if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; } return parseInt(val, 10) || 0; } function formatBgpTime(secs) { if (!secs || secs <= 0) return '00:00:00'; const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); const s = secs % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function populateBgpRoutingTables() { const sel = document.getElementById('modal-bgp-peer-routing-table'); if (!sel) return; const cur = sel.value || 'main'; sel.innerHTML = ''; (state.routingTables || []).forEach(t => { if (t.name !== 'main') { const opt = document.createElement('option'); opt.value = t.name; opt.innerText = t.name; sel.appendChild(opt); } }); sel.value = cur; } function openAddBgpPeerModal() { document.getElementById('bgp-peer-form').reset(); document.getElementById('modal-bgp-peer-id').value = ''; document.getElementById('bgp-peer-modal-title').innerText = 'New...'; // General Tab document.getElementById('modal-bgp-peer-enabled').checked = true; document.getElementById('modal-bgp-peer-comment').value = ''; document.getElementById('modal-bgp-peer-name').value = ''; if (document.getElementById('modal-bgp-peer-instance')) document.getElementById('modal-bgp-peer-instance').value = 'bgp-instance-1'; if (document.getElementById('modal-bgp-peer-template')) document.getElementById('modal-bgp-peer-template').value = ''; document.getElementById('modal-bgp-peer-local-as').value = ''; document.getElementById('modal-bgp-peer-afi-ip').checked = true; document.getElementById('modal-bgp-peer-afi-ipv6').checked = false; if (document.getElementById('modal-bgp-peer-afi-l2vpn')) document.getElementById('modal-bgp-peer-afi-l2vpn').checked = false; if (document.getElementById('modal-bgp-peer-afi-vpnv4')) document.getElementById('modal-bgp-peer-afi-vpnv4').checked = false; if (document.getElementById('modal-bgp-peer-afi-l2vpn-cisco')) document.getElementById('modal-bgp-peer-afi-l2vpn-cisco').checked = false; document.getElementById('modal-bgp-peer-router-id').value = ''; document.getElementById('modal-bgp-peer-remote-addr').value = ''; document.getElementById('modal-bgp-peer-remote-port').value = ''; document.getElementById('modal-bgp-peer-remote-as').value = ''; if (document.getElementById('modal-bgp-peer-remote-allow-as')) document.getElementById('modal-bgp-peer-remote-allow-as').value = ''; document.getElementById('modal-bgp-peer-local-addr').value = ''; document.getElementById('modal-bgp-peer-local-port').value = ''; document.getElementById('modal-bgp-peer-local-role').value = 'ebgp'; document.getElementById('modal-bgp-peer-password').value = ''; document.getElementById('modal-bgp-peer-multihop').checked = false; if (document.getElementById('modal-bgp-peer-tx-ttl')) document.getElementById('modal-bgp-peer-tx-ttl').value = ''; if (document.getElementById('modal-bgp-peer-rx-min-ttl')) document.getElementById('modal-bgp-peer-rx-min-ttl').value = ''; document.getElementById('modal-bgp-peer-connect').checked = true; document.getElementById('modal-bgp-peer-listen').checked = true; // Extra Tab document.getElementById('modal-bgp-peer-holdtime').value = '00:00:30'; document.getElementById('modal-bgp-peer-keepalive').value = '00:00:10'; if (document.getElementById('modal-bgp-peer-use-bfd')) document.getElementById('modal-bgp-peer-use-bfd').checked = false; if (document.getElementById('modal-bgp-peer-vrf')) document.getElementById('modal-bgp-peer-vrf').value = 'main'; if (document.getElementById('modal-bgp-peer-no-client-reflect')) document.getElementById('modal-bgp-peer-no-client-reflect').checked = true; document.getElementById('modal-bgp-peer-redist-conn').checked = false; document.getElementById('modal-bgp-peer-redist-static').checked = true; if (document.getElementById('modal-bgp-peer-redist-rip')) document.getElementById('modal-bgp-peer-redist-rip').checked = false; document.getElementById('modal-bgp-peer-redist-ospf').checked = false; document.getElementById('modal-bgp-peer-redist-bgp').checked = false; if (document.getElementById('modal-bgp-peer-redist-vpn')) document.getElementById('modal-bgp-peer-redist-vpn').checked = false; if (document.getElementById('modal-bgp-peer-redist-dhcp')) document.getElementById('modal-bgp-peer-redist-dhcp').checked = false; if (document.getElementById('modal-bgp-peer-redist-bgp-mpls-vpn')) document.getElementById('modal-bgp-peer-redist-bgp-mpls-vpn').checked = false; if (document.getElementById('modal-bgp-peer-redist-slaac')) document.getElementById('modal-bgp-peer-redist-slaac').checked = false; document.getElementById('modal-bgp-peer-default-orig').checked = false; if (document.getElementById('modal-bgp-peer-network-blackhole')) document.getElementById('modal-bgp-peer-network-blackhole').checked = false; if (document.getElementById('modal-bgp-peer-no-early-cut')) document.getElementById('modal-bgp-peer-no-early-cut').checked = false; if (document.getElementById('modal-bgp-peer-keep-sent-attrs')) document.getElementById('modal-bgp-peer-keep-sent-attrs').checked = false; if (document.getElementById('modal-bgp-peer-input-affinity')) document.getElementById('modal-bgp-peer-input-affinity').value = ''; if (document.getElementById('modal-bgp-peer-output-affinity')) document.getElementById('modal-bgp-peer-output-affinity').value = ''; // Attributes Tab document.getElementById('modal-bgp-peer-nexthop-choice').value = 'force-self'; document.getElementById('modal-bgp-peer-default-prepend').value = 0; document.getElementById('modal-bgp-peer-allow-as-in').value = 0; if (document.getElementById('modal-bgp-peer-ignore-as-path-len')) document.getElementById('modal-bgp-peer-ignore-as-path-len').checked = false; document.getElementById('modal-bgp-peer-remove-priv-as').checked = false; document.getElementById('modal-bgp-peer-as-override').checked = false; // Filter Tab populateFilterChainsDatalist(); document.getElementById('modal-bgp-peer-in-filter').value = ''; if (document.getElementById('modal-bgp-peer-in-accept-nlri')) document.getElementById('modal-bgp-peer-in-accept-nlri').value = ''; if (document.getElementById('modal-bgp-peer-in-accept-comm')) document.getElementById('modal-bgp-peer-in-accept-comm').value = ''; if (document.getElementById('modal-bgp-peer-in-accept-ext-comm')) document.getElementById('modal-bgp-peer-in-accept-ext-comm').value = ''; if (document.getElementById('modal-bgp-peer-in-accept-large-comm')) document.getElementById('modal-bgp-peer-in-accept-large-comm').value = ''; if (document.getElementById('modal-bgp-peer-in-filter-comm')) document.getElementById('modal-bgp-peer-in-filter-comm').value = ''; if (document.getElementById('modal-bgp-peer-in-filter-ext-comm')) document.getElementById('modal-bgp-peer-in-filter-ext-comm').value = ''; if (document.getElementById('modal-bgp-peer-in-filter-large-comm')) document.getElementById('modal-bgp-peer-in-filter-large-comm').value = ''; if (document.getElementById('modal-bgp-peer-in-filter-unknown')) document.getElementById('modal-bgp-peer-in-filter-unknown').checked = false; document.getElementById('modal-bgp-peer-out-filter').value = ''; if (document.getElementById('modal-bgp-peer-out-selection-policy')) document.getElementById('modal-bgp-peer-out-selection-policy').value = 'disabled'; if (document.getElementById('modal-bgp-peer-out-network-name')) document.getElementById('modal-bgp-peer-out-network-name').value = ''; populateBgpRoutingTables(); const container = document.getElementById('modal-bgp-output-networks-container'); if (container) { container.innerHTML = ''; addOutputNetworkRow(''); } const removeBtn = document.getElementById('modal-btn-remove-peer'); if (removeBtn) removeBtn.classList.add('hidden'); switchBgpPeerModalTab('general'); document.getElementById('bgp-peer-modal').classList.remove('hidden'); } function populateFilterChainsDatalist() { const dl = document.getElementById('filter-chains-list'); if (!dl) return; const chains = new Set(); (state.routingFilters || []).forEach(f => { if (f.chain) chains.add(f.chain); }); if (chains.size === 0) { chains.add('awmn'); chains.add('bgp-in'); chains.add('bgp-out'); } dl.innerHTML = ''; chains.forEach(c => { const opt = document.createElement('option'); opt.value = c; dl.appendChild(opt); }); } function openEditBgpPeerModal(id) { const p = (state.bgpPeers || []).find(item => item.id === id); if (!p) return; populateBgpRoutingTables(); populateFilterChainsDatalist(); document.getElementById('modal-bgp-peer-id').value = p.id; document.getElementById('bgp-peer-modal-title').innerText = p.name || p.id; // General Tab document.getElementById('modal-bgp-peer-enabled').checked = p.enabled !== false && !p.disabled; document.getElementById('modal-bgp-peer-comment').value = p.comment || ''; document.getElementById('modal-bgp-peer-name').value = p.name || ''; if (document.getElementById('modal-bgp-peer-instance')) document.getElementById('modal-bgp-peer-instance').value = p.instance || 'bgp-instance-1'; if (document.getElementById('modal-bgp-peer-template')) document.getElementById('modal-bgp-peer-template').value = p.template || ''; document.getElementById('modal-bgp-peer-local-as').value = p.local_as || ''; document.getElementById('modal-bgp-peer-router-id').value = p.router_id || ''; document.getElementById('modal-bgp-peer-afi-ip').checked = true; document.getElementById('modal-bgp-peer-afi-ipv6').checked = p.afi === 'ipv6'; document.getElementById('modal-bgp-peer-remote-addr').value = p.remote_address || ''; document.getElementById('modal-bgp-peer-remote-port').value = p.remote_port || ''; document.getElementById('modal-bgp-peer-remote-as').value = p.remote_as || ''; if (document.getElementById('modal-bgp-peer-remote-allow-as')) document.getElementById('modal-bgp-peer-remote-allow-as').value = p.remote_allow_as || ''; document.getElementById('modal-bgp-peer-local-addr').value = p.local_address || ''; document.getElementById('modal-bgp-peer-local-port').value = p.local_port || ''; document.getElementById('modal-bgp-peer-local-role').value = p.local_role || 'ebgp'; document.getElementById('modal-bgp-peer-password').value = p.password || ''; document.getElementById('modal-bgp-peer-multihop').checked = !!p.multihop; if (document.getElementById('modal-bgp-peer-tx-ttl')) document.getElementById('modal-bgp-peer-tx-ttl').value = p.tx_ttl || ''; if (document.getElementById('modal-bgp-peer-rx-min-ttl')) document.getElementById('modal-bgp-peer-rx-min-ttl').value = p.rx_min_ttl || ''; document.getElementById('modal-bgp-peer-connect').checked = p.connect !== false; document.getElementById('modal-bgp-peer-listen').checked = p.listen !== false; // Extra Tab document.getElementById('modal-bgp-peer-holdtime').value = p.hold_time ? formatBgpTime(p.hold_time) : '00:00:30'; document.getElementById('modal-bgp-peer-keepalive').value = p.keepalive_time ? formatBgpTime(p.keepalive_time) : '00:00:10'; if (document.getElementById('modal-bgp-peer-use-bfd')) document.getElementById('modal-bgp-peer-use-bfd').checked = !!p.use_bfd; document.getElementById('modal-bgp-peer-routing-table').value = p.routing_table || 'main'; if (document.getElementById('modal-bgp-peer-vrf')) document.getElementById('modal-bgp-peer-vrf').value = p.vrf || 'main'; if (document.getElementById('modal-bgp-peer-no-client-reflect')) document.getElementById('modal-bgp-peer-no-client-reflect').checked = p.no_client_reflect !== false; document.getElementById('modal-bgp-peer-redist-conn').checked = !!p.redist_connected; document.getElementById('modal-bgp-peer-redist-static').checked = p.redist_static !== false; if (document.getElementById('modal-bgp-peer-redist-rip')) document.getElementById('modal-bgp-peer-redist-rip').checked = !!p.redist_rip; document.getElementById('modal-bgp-peer-redist-ospf').checked = !!p.redist_ospf; document.getElementById('modal-bgp-peer-redist-bgp').checked = !!p.redist_bgp; if (document.getElementById('modal-bgp-peer-redist-vpn')) document.getElementById('modal-bgp-peer-redist-vpn').checked = !!p.redist_vpn; if (document.getElementById('modal-bgp-peer-redist-dhcp')) document.getElementById('modal-bgp-peer-redist-dhcp').checked = !!p.redist_dhcp; if (document.getElementById('modal-bgp-peer-redist-bgp-mpls-vpn')) document.getElementById('modal-bgp-peer-redist-bgp-mpls-vpn').checked = !!p.redist_bgp_mpls_vpn; if (document.getElementById('modal-bgp-peer-redist-slaac')) document.getElementById('modal-bgp-peer-redist-slaac').checked = !!p.redist_slaac; document.getElementById('modal-bgp-peer-default-orig').checked = !!p.default_originate; if (document.getElementById('modal-bgp-peer-network-blackhole')) document.getElementById('modal-bgp-peer-network-blackhole').checked = !!p.network_blackhole; if (document.getElementById('modal-bgp-peer-no-early-cut')) document.getElementById('modal-bgp-peer-no-early-cut').checked = !!p.no_early_cut; if (document.getElementById('modal-bgp-peer-keep-sent-attrs')) document.getElementById('modal-bgp-peer-keep-sent-attrs').checked = !!p.keep_sent_attrs; if (document.getElementById('modal-bgp-peer-input-affinity')) document.getElementById('modal-bgp-peer-input-affinity').value = p.input_affinity || ''; if (document.getElementById('modal-bgp-peer-output-affinity')) document.getElementById('modal-bgp-peer-output-affinity').value = p.output_affinity || ''; // Attributes Tab document.getElementById('modal-bgp-peer-nexthop-choice').value = p.nexthop_choice || (p.nexthop_self ? 'force-self' : 'default'); document.getElementById('modal-bgp-peer-default-prepend').value = p.default_prepend || 0; document.getElementById('modal-bgp-peer-allow-as-in').value = p.allow_as_in || 0; if (document.getElementById('modal-bgp-peer-ignore-as-path-len')) document.getElementById('modal-bgp-peer-ignore-as-path-len').checked = !!p.ignore_as_path_len; document.getElementById('modal-bgp-peer-remove-priv-as').checked = !!p.remove_private_as; document.getElementById('modal-bgp-peer-as-override').checked = !!p.as_override; // Filter Tab document.getElementById('modal-bgp-peer-in-filter').value = p.in_filter || ''; if (document.getElementById('modal-bgp-peer-in-accept-nlri')) document.getElementById('modal-bgp-peer-in-accept-nlri').value = p.in_accept_nlri || ''; if (document.getElementById('modal-bgp-peer-in-accept-comm')) document.getElementById('modal-bgp-peer-in-accept-comm').value = p.in_accept_communities || ''; if (document.getElementById('modal-bgp-peer-in-accept-ext-comm')) document.getElementById('modal-bgp-peer-in-accept-ext-comm').value = p.in_accept_ext_communities || ''; if (document.getElementById('modal-bgp-peer-in-accept-large-comm')) document.getElementById('modal-bgp-peer-in-accept-large-comm').value = p.in_accept_large_communities || ''; if (document.getElementById('modal-bgp-peer-in-filter-comm')) document.getElementById('modal-bgp-peer-in-filter-comm').value = p.in_filter_communities || ''; if (document.getElementById('modal-bgp-peer-in-filter-ext-comm')) document.getElementById('modal-bgp-peer-in-filter-ext-comm').value = p.in_filter_ext_communities || ''; if (document.getElementById('modal-bgp-peer-in-filter-large-comm')) document.getElementById('modal-bgp-peer-in-filter-large-comm').value = p.in_filter_large_communities || ''; if (document.getElementById('modal-bgp-peer-in-filter-unknown')) document.getElementById('modal-bgp-peer-in-filter-unknown').checked = !!p.in_filter_unknown; document.getElementById('modal-bgp-peer-out-filter').value = p.out_filter || ''; if (document.getElementById('modal-bgp-peer-out-selection-policy')) document.getElementById('modal-bgp-peer-out-selection-policy').value = p.out_selection_policy || 'disabled'; if (document.getElementById('modal-bgp-peer-out-network-name')) document.getElementById('modal-bgp-peer-out-network-name').value = p.output_network || ''; // Output Networks const container = document.getElementById('modal-bgp-output-networks-container'); if (container) { container.innerHTML = ''; if (p.output_networks && p.output_networks.length > 0) { p.output_networks.forEach(net => addOutputNetworkRow(net)); } else { addOutputNetworkRow(''); } } const removeBtn = document.getElementById('modal-btn-remove-peer'); if (removeBtn) removeBtn.classList.remove('hidden'); switchBgpPeerModalTab('general'); document.getElementById('bgp-peer-modal').classList.remove('hidden'); } function copyCurrentBgpPeer() { document.getElementById('modal-bgp-peer-id').value = ''; const nameEl = document.getElementById('modal-bgp-peer-name'); if (nameEl) nameEl.value = (nameEl.value || 'peer') + '-copy'; document.getElementById('bgp-peer-modal-title').innerText = 'New...'; const removeBtn = document.getElementById('modal-btn-remove-peer'); if (removeBtn) removeBtn.classList.add('hidden'); showToast('Cloned', 'BGP connection copied to new draft', 'info'); } function removeCurrentBgpPeer() { const id = document.getElementById('modal-bgp-peer-id')?.value; if (!id) return; deleteBgpPeer(id); closeBgpPeerModal(); } function closeBgpPeerModal() { document.getElementById('bgp-peer-modal').classList.add('hidden'); } function getBgpPeerFormData() { const netInputs = document.querySelectorAll('#modal-bgp-output-networks-container .output-net-row input'); const outputNetworks = []; netInputs.forEach(inp => { const v = inp.value.trim(); if (v) outputNetworks.push(v); }); const enabled = document.getElementById('modal-bgp-peer-enabled').checked; return { id: document.getElementById('modal-bgp-peer-id').value, name: document.getElementById('modal-bgp-peer-name').value.trim(), enabled: enabled, disabled: !enabled, comment: document.getElementById('modal-bgp-peer-comment').value.trim(), instance: document.getElementById('modal-bgp-peer-instance')?.value.trim() || 'bgp-instance-1', template: document.getElementById('modal-bgp-peer-template')?.value.trim() || '', local_as: parseInt(document.getElementById('modal-bgp-peer-local-as')?.value, 10) || 0, router_id: document.getElementById('modal-bgp-peer-router-id').value.trim(), afi: document.getElementById('modal-bgp-peer-afi-ipv6').checked ? 'ipv6' : 'ip', remote_address: document.getElementById('modal-bgp-peer-remote-addr').value.trim(), remote_port: parseInt(document.getElementById('modal-bgp-peer-remote-port').value, 10) || 0, remote_as: parseInt(document.getElementById('modal-bgp-peer-remote-as').value, 10) || 0, remote_allow_as: document.getElementById('modal-bgp-peer-remote-allow-as')?.value.trim() || '', local_address: document.getElementById('modal-bgp-peer-local-addr').value.trim(), local_port: parseInt(document.getElementById('modal-bgp-peer-local-port').value, 10) || 0, local_role: document.getElementById('modal-bgp-peer-local-role').value, password: document.getElementById('modal-bgp-peer-password').value, multihop: document.getElementById('modal-bgp-peer-multihop').checked, tx_ttl: parseInt(document.getElementById('modal-bgp-peer-tx-ttl')?.value, 10) || 0, rx_min_ttl: parseInt(document.getElementById('modal-bgp-peer-rx-min-ttl')?.value, 10) || 0, connect: document.getElementById('modal-bgp-peer-connect').checked, listen: document.getElementById('modal-bgp-peer-listen').checked, hold_time: parseBgpTime(document.getElementById('modal-bgp-peer-holdtime').value), keepalive_time: parseBgpTime(document.getElementById('modal-bgp-peer-keepalive').value), use_bfd: document.getElementById('modal-bgp-peer-use-bfd')?.checked || false, routing_table: document.getElementById('modal-bgp-peer-routing-table').value, vrf: document.getElementById('modal-bgp-peer-vrf')?.value.trim() || 'main', no_client_reflect: document.getElementById('modal-bgp-peer-no-client-reflect')?.checked || false, redist_connected: document.getElementById('modal-bgp-peer-redist-conn').checked, redist_static: document.getElementById('modal-bgp-peer-redist-static').checked, redist_rip: document.getElementById('modal-bgp-peer-redist-rip')?.checked || false, redist_ospf: document.getElementById('modal-bgp-peer-redist-ospf').checked, redist_bgp: document.getElementById('modal-bgp-peer-redist-bgp').checked, redist_vpn: document.getElementById('modal-bgp-peer-redist-vpn')?.checked || false, redist_dhcp: document.getElementById('modal-bgp-peer-redist-dhcp')?.checked || false, redist_bgp_mpls_vpn: document.getElementById('modal-bgp-peer-redist-bgp-mpls-vpn')?.checked || false, redist_slaac: document.getElementById('modal-bgp-peer-redist-slaac')?.checked || false, default_originate: document.getElementById('modal-bgp-peer-default-orig').checked, network_blackhole: document.getElementById('modal-bgp-peer-network-blackhole')?.checked || false, no_early_cut: document.getElementById('modal-bgp-peer-no-early-cut')?.checked || false, keep_sent_attrs: document.getElementById('modal-bgp-peer-keep-sent-attrs')?.checked || false, input_affinity: (document.getElementById('modal-bgp-peer-input-affinity')?.value || '').trim(), output_affinity: (document.getElementById('modal-bgp-peer-output-affinity')?.value || '').trim(), nexthop_choice: document.getElementById('modal-bgp-peer-nexthop-choice').value, nexthop_self: document.getElementById('modal-bgp-peer-nexthop-choice').value === 'force-self', default_prepend: parseInt(document.getElementById('modal-bgp-peer-default-prepend').value, 10) || 0, allow_as_in: parseInt(document.getElementById('modal-bgp-peer-allow-as-in').value, 10) || 0, ignore_as_path_len: document.getElementById('modal-bgp-peer-ignore-as-path-len')?.checked || false, remove_private_as: document.getElementById('modal-bgp-peer-remove-priv-as').checked, as_override: document.getElementById('modal-bgp-peer-as-override').checked, in_filter: (document.getElementById('modal-bgp-peer-in-filter')?.value || '').trim(), in_accept_nlri: (document.getElementById('modal-bgp-peer-in-accept-nlri')?.value || '').trim(), in_accept_communities: (document.getElementById('modal-bgp-peer-in-accept-comm')?.value || '').trim(), in_accept_ext_communities: (document.getElementById('modal-bgp-peer-in-accept-ext-comm')?.value || '').trim(), in_accept_large_communities: (document.getElementById('modal-bgp-peer-in-accept-large-comm')?.value || '').trim(), in_filter_communities: (document.getElementById('modal-bgp-peer-in-filter-comm')?.value || '').trim(), in_filter_ext_communities: (document.getElementById('modal-bgp-peer-in-filter-ext-comm')?.value || '').trim(), in_filter_large_communities: (document.getElementById('modal-bgp-peer-in-filter-large-comm')?.value || '').trim(), in_filter_unknown: document.getElementById('modal-bgp-peer-in-filter-unknown')?.checked || false, out_filter: (document.getElementById('modal-bgp-peer-out-filter')?.value || '').trim(), out_selection_policy: document.getElementById('modal-bgp-peer-out-selection-policy')?.value || 'disabled', output_network: (document.getElementById('modal-bgp-peer-out-network-name')?.value || '').trim(), output_networks: outputNetworks }; } async function submitBgpPeer(e) { if (e) e.preventDefault(); const payload = getBgpPeerFormData(); 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 Connection 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 applyBgpPeerModal(e) { if (e) e.preventDefault(); const payload = getBgpPeerFormData(); try { const res = await fetch(`${API_BASE}/routing/bgp/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { const data = await res.json(); if (data.peer && data.peer.id) { document.getElementById('modal-bgp-peer-id').value = data.peer.id; document.getElementById('bgp-peer-modal-title').innerText = data.peer.name || data.peer.id; document.getElementById('modal-btn-remove-peer')?.classList.remove('hidden'); } showToast('Applied', 'BGP Connection applied', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to apply 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'); } } // ========================================================================= // DEBTIK v7 OSPF MODALS & ACTIONS // ========================================================================= // --- 1. OSPF INSTANCE --- function openOspfInstanceModal(id = null) { const isEdit = !!id; const inst = (state.ospfInstances || []).find(i => i.id === id) || { id: '', name: 'Default', version: 2, vrf: 'main', router_id: '192.168.131.56', routing_table: 'main', enabled: true, redistribute_connected: false, redistribute_static: true, redistribute_bgp: false, comment: '' }; document.getElementById('ospf-instance-modal-title').innerText = isEdit ? inst.name : 'New...'; document.getElementById('modal-ospf-inst-id').value = isEdit ? inst.id : ''; document.getElementById('modal-ospf-inst-enabled').checked = !inst.disabled; document.getElementById('modal-ospf-inst-comment').value = inst.comment || ''; document.getElementById('modal-ospf-inst-name').value = inst.name || 'Default'; document.getElementById('modal-ospf-inst-version').value = inst.version || 2; document.getElementById('modal-ospf-inst-vrf').value = inst.vrf || 'main'; document.getElementById('modal-ospf-inst-router-id').value = inst.router_id || ''; document.getElementById('modal-ospf-inst-routing-table').value = inst.routing_table || 'main'; document.getElementById('modal-ospf-inst-redist-conn').checked = !!inst.redistribute_connected; document.getElementById('modal-ospf-inst-redist-static').checked = inst.redistribute_static !== false; document.getElementById('modal-ospf-inst-redist-bgp').checked = !!inst.redistribute_bgp; const btnRem = document.getElementById('btn-ospf-inst-modal-remove'); if (btnRem) btnRem.style.display = isEdit ? 'block' : 'none'; document.getElementById('ospf-instance-modal').classList.remove('hidden'); } function openEditOspfInstanceModal(id) { openOspfInstanceModal(id); } function closeOspfInstanceModal() { document.getElementById('ospf-instance-modal').classList.add('hidden'); } async function submitOspfInstance(e) { if (e) e.preventDefault(); const payload = { id: document.getElementById('modal-ospf-inst-id').value, name: document.getElementById('modal-ospf-inst-name').value.trim() || 'Default', version: parseInt(document.getElementById('modal-ospf-inst-version').value, 10) || 2, vrf: document.getElementById('modal-ospf-inst-vrf').value.trim() || 'main', router_id: document.getElementById('modal-ospf-inst-router-id').value.trim(), routing_table: document.getElementById('modal-ospf-inst-routing-table').value.trim() || 'main', enabled: document.getElementById('modal-ospf-inst-enabled').checked, disabled: !document.getElementById('modal-ospf-inst-enabled').checked, comment: document.getElementById('modal-ospf-inst-comment').value.trim(), redistribute_connected: document.getElementById('modal-ospf-inst-redist-conn').checked, redistribute_static: document.getElementById('modal-ospf-inst-redist-static').checked, redistribute_bgp: document.getElementById('modal-ospf-inst-redist-bgp').checked }; try { const res = await fetch(`${API_BASE}/routing/ospf/instances`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeOspfInstanceModal(); showToast('Saved', 'OSPF instance updated', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save OSPF instance', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function applyOspfInstanceForm() { submitOspfInstance(null); } function copyOspfInstance() { document.getElementById('modal-ospf-inst-id').value = ''; document.getElementById('modal-ospf-inst-name').value += '-copy'; document.getElementById('ospf-instance-modal-title').innerText = 'New...'; showToast('Copied', 'Duplicated as new OSPF instance', 'info'); } async function deleteCurrentOspfInstance() { const id = document.getElementById('modal-ospf-inst-id').value; if (!id) return; if (!confirm('Are you sure you want to remove this OSPF instance?')) return; try { const res = await fetch(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { closeOspfInstanceModal(); showToast('Removed', 'OSPF instance deleted', 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function enableSelectedOspfInstance() { const id = state.selectedOspfRow?.instances; if (!id) return; await fetch(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) }); loadDynamicRouting(); } async function disableSelectedOspfInstance() { const id = state.selectedOspfRow?.instances; if (!id) return; await fetch(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) }); loadDynamicRouting(); } async function removeSelectedOspfInstance() { const id = state.selectedOspfRow?.instances; if (!id) return; if (!confirm('Delete selected OSPF instance?')) return; await fetch(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }); loadDynamicRouting(); } async function promptCommentOspfInstance() { const id = state.selectedOspfRow?.instances; if (!id) return; const inst = (state.ospfInstances || []).find(i => i.id === id); if (!inst) return; const comment = prompt('Enter comment for OSPF instance:', inst.comment || ''); if (comment === null) return; inst.comment = comment; await fetch(`${API_BASE}/routing/ospf/instances`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(inst) }); loadDynamicRouting(); } // --- 2. OSPF AREA --- function populateOspfAreaDropdowns() { const instSelect = document.getElementById('modal-ospf-area-instance'); if (instSelect) { const cur = instSelect.value; instSelect.innerHTML = (state.ospfInstances || []).map(i => ``).join(''); if (instSelect.innerHTML === '') instSelect.innerHTML = ''; if (cur) instSelect.value = cur; } } function openOspfAreaModal(id = null) { const isEdit = !!id; populateOspfAreaDropdowns(); const area = (state.ospfAreas || []).find(a => a.id === id) || { id: '', name: 'Default', instance: 'Default', area_id: '0.0.0.0', area_type: 'default', default_cost: 1, no_summaries: false, disabled: false, comment: '' }; document.getElementById('ospf-area-modal-title').innerText = isEdit ? area.name : 'New...'; document.getElementById('modal-ospf-area-id').value = isEdit ? area.id : ''; document.getElementById('modal-ospf-area-enabled').checked = !area.disabled; document.getElementById('modal-ospf-area-comment').value = area.comment || ''; document.getElementById('modal-ospf-area-name').value = area.name || 'Default'; document.getElementById('modal-ospf-area-instance').value = area.instance || 'Default'; document.getElementById('modal-ospf-area-areaid').value = area.area_id || '0.0.0.0'; document.getElementById('modal-ospf-area-type').value = area.area_type || 'default'; document.getElementById('modal-ospf-area-default-cost').value = area.default_cost || 1; document.getElementById('modal-ospf-area-no-summaries').checked = !!area.no_summaries; const btnRem = document.getElementById('btn-ospf-area-modal-remove'); if (btnRem) btnRem.style.display = isEdit ? 'block' : 'none'; document.getElementById('ospf-area-modal').classList.remove('hidden'); } function openEditOspfAreaModal(id) { openOspfAreaModal(id); } function closeOspfAreaModal() { document.getElementById('ospf-area-modal').classList.add('hidden'); } async function submitOspfArea(e) { if (e) e.preventDefault(); const payload = { id: document.getElementById('modal-ospf-area-id').value, name: document.getElementById('modal-ospf-area-name').value.trim() || 'Default', instance: document.getElementById('modal-ospf-area-instance').value.trim() || 'Default', area_id: document.getElementById('modal-ospf-area-areaid').value.trim() || '0.0.0.0', area_type: document.getElementById('modal-ospf-area-type').value, default_cost: parseInt(document.getElementById('modal-ospf-area-default-cost').value, 10) || 1, no_summaries: document.getElementById('modal-ospf-area-no-summaries').checked, disabled: !document.getElementById('modal-ospf-area-enabled').checked, 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('Saved', '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'); } } function applyOspfAreaForm() { submitOspfArea(null); } function copyOspfArea() { document.getElementById('modal-ospf-area-id').value = ''; document.getElementById('modal-ospf-area-name').value += '-copy'; document.getElementById('ospf-area-modal-title').innerText = 'New...'; showToast('Copied', 'Duplicated as new OSPF area', 'info'); } async function deleteCurrentOspfArea() { const id = document.getElementById('modal-ospf-area-id').value; if (!id) return; if (!confirm('Are you sure you want to remove this OSPF area?')) return; try { const res = await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { closeOspfAreaModal(); showToast('Removed', 'OSPF area deleted', 'success'); loadDynamicRouting(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function enableSelectedOspfArea() { const id = state.selectedOspfRow?.areas; if (!id) return; await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) }); loadDynamicRouting(); } async function disableSelectedOspfArea() { const id = state.selectedOspfRow?.areas; if (!id) return; await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) }); loadDynamicRouting(); } async function removeSelectedOspfArea() { const id = state.selectedOspfRow?.areas; if (!id) return; if (!confirm('Delete selected OSPF area?')) return; await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' }); loadDynamicRouting(); } async function promptCommentOspfArea() { const id = state.selectedOspfRow?.areas; if (!id) return; const area = (state.ospfAreas || []).find(a => a.id === id); if (!area) return; const comment = prompt('Enter comment for OSPF area:', area.comment || ''); if (comment === null) return; area.comment = comment; await fetch(`${API_BASE}/routing/ospf/areas`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(area) }); loadDynamicRouting(); } // --- 3. OSPF AREA RANGE --- function populateAreaRangeDropdowns() { const select = document.getElementById('modal-ospf-range-area'); if (select) { const cur = select.value; select.innerHTML = (state.ospfAreas || []).map(a => ``).join(''); if (select.innerHTML === '') select.innerHTML = ''; if (cur) select.value = cur; } } function openOspfAreaRangeModal(id = null) { const isEdit = !!id; populateAreaRangeDropdowns(); const r = (state.ospfAreaRanges || []).find(it => it.id === id) || { id: '', area: 'Default', prefix: '', cost: 1, advertise: true, disabled: false, comment: '' }; document.getElementById('ospf-area-range-modal-title').innerText = isEdit ? r.prefix : 'New...'; document.getElementById('modal-ospf-range-id').value = isEdit ? r.id : ''; document.getElementById('modal-ospf-range-enabled').checked = !r.disabled; document.getElementById('modal-ospf-range-comment').value = r.comment || ''; document.getElementById('modal-ospf-range-area').value = r.area || 'Default'; document.getElementById('modal-ospf-range-prefix').value = r.prefix || ''; document.getElementById('modal-ospf-range-cost').value = r.cost || 1; document.getElementById('modal-ospf-range-advertise').checked = r.advertise !== false; const btnRem = document.getElementById('btn-ospf-range-modal-remove'); if (btnRem) btnRem.style.display = isEdit ? 'block' : 'none'; document.getElementById('ospf-area-range-modal').classList.remove('hidden'); } function openEditOspfAreaRangeModal(id) { openOspfAreaRangeModal(id); } function closeOspfAreaRangeModal() { document.getElementById('ospf-area-range-modal').classList.add('hidden'); } async function submitOspfAreaRange(e) { if (e) e.preventDefault(); const payload = { id: document.getElementById('modal-ospf-range-id').value, area: document.getElementById('modal-ospf-range-area').value, prefix: document.getElementById('modal-ospf-range-prefix').value.trim(), cost: parseInt(document.getElementById('modal-ospf-range-cost').value, 10) || 1, advertise: document.getElementById('modal-ospf-range-advertise').checked, disabled: !document.getElementById('modal-ospf-range-enabled').checked, comment: document.getElementById('modal-ospf-range-comment').value.trim() }; try { const res = await fetch(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeOspfAreaRangeModal(); showToast('Saved', 'Area range saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save area range', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function applyOspfAreaRangeForm() { submitOspfAreaRange(null); } async function deleteCurrentOspfAreaRange() { const id = document.getElementById('modal-ospf-range-id').value; if (!id) return; if (!confirm('Delete this area range?')) return; try { await fetch(`${API_BASE}/routing/ospf/area-ranges/${encodeURIComponent(id)}`, { method: 'DELETE' }); closeOspfAreaRangeModal(); showToast('Removed', 'Area range removed', 'success'); loadDynamicRouting(); } catch (err) { showToast('Error', err.message, 'error'); } } async function enableSelectedOspfAreaRange() { const id = state.selectedOspfRow?.['area-ranges']; if (!id) return; const r = (state.ospfAreaRanges || []).find(it => it.id === id); if (!r) return; r.disabled = false; await fetch(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(r) }); loadDynamicRouting(); } async function disableSelectedOspfAreaRange() { const id = state.selectedOspfRow?.['area-ranges']; if (!id) return; const r = (state.ospfAreaRanges || []).find(it => it.id === id); if (!r) return; r.disabled = true; await fetch(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(r) }); loadDynamicRouting(); } async function removeSelectedOspfAreaRange() { const id = state.selectedOspfRow?.['area-ranges']; if (!id) return; if (!confirm('Delete selected area range?')) return; await fetch(`${API_BASE}/routing/ospf/area-ranges/${encodeURIComponent(id)}`, { method: 'DELETE' }); loadDynamicRouting(); } async function promptCommentOspfAreaRange() { const id = state.selectedOspfRow?.['area-ranges']; if (!id) return; const r = (state.ospfAreaRanges || []).find(it => it.id === id); if (!r) return; const comment = prompt('Enter comment for area range:', r.comment || ''); if (comment === null) return; r.comment = comment; await fetch(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(r) }); loadDynamicRouting(); } // --- 4. OSPF INTERFACE TEMPLATE --- function switchOspfTemplateTab(tab) { const tabGen = document.getElementById('ospf-tmpl-tab-general'); const tabAuth = document.getElementById('ospf-tmpl-tab-auth'); const btnGen = document.getElementById('ospf-tmpl-tab-btn-general'); const btnAuth = document.getElementById('ospf-tmpl-tab-btn-auth'); if (tab === 'general') { if (tabGen) tabGen.classList.remove('hidden'); if (tabAuth) tabAuth.classList.add('hidden'); if (btnGen) btnGen.className = 'px-4 py-2 text-xs font-bold border-b-2 border-sky-500 text-white transition'; if (btnAuth) btnAuth.className = 'px-4 py-2 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition'; } else { if (tabGen) tabGen.classList.add('hidden'); if (tabAuth) tabAuth.classList.remove('hidden'); if (btnGen) btnGen.className = 'px-4 py-2 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition'; if (btnAuth) btnAuth.className = 'px-4 py-2 text-xs font-bold border-b-2 border-sky-500 text-white transition'; } } function populateTemplateAreaDropdowns() { const select = document.getElementById('modal-ospf-tmpl-area'); if (select) { const cur = select.value; select.innerHTML = (state.ospfAreas || []).map(a => ``).join(''); if (select.innerHTML === '') select.innerHTML = ''; if (cur) select.value = cur; } } function openOspfTemplateModal(id = null) { const isEdit = !!id; populateTemplateAreaDropdowns(); switchOspfTemplateTab('general'); const tmpl = (state.ospfTemplates || []).find(t => t.id === id) || { id: '', interfaces: '', area: 'Default', networks: '', network_type: 'broadcast', cost: 1, priority: 128, passive: false, hello_interval: 10, dead_interval: 40, auth: 'none', auth_key: '', disabled: false, comment: '' }; document.getElementById('ospf-template-modal-title').innerText = isEdit ? (tmpl.interfaces || tmpl.networks || 'Template') : 'New...'; document.getElementById('modal-ospf-tmpl-id').value = isEdit ? tmpl.id : ''; document.getElementById('modal-ospf-tmpl-enabled').checked = !tmpl.disabled; document.getElementById('modal-ospf-tmpl-comment').value = tmpl.comment || ''; document.getElementById('modal-ospf-tmpl-interfaces').value = tmpl.interfaces || tmpl.interface || ''; document.getElementById('modal-ospf-tmpl-area').value = tmpl.area || 'Default'; document.getElementById('modal-ospf-tmpl-networks').value = tmpl.networks || tmpl.network || ''; document.getElementById('modal-ospf-tmpl-net-type').value = tmpl.network_type || 'broadcast'; document.getElementById('modal-ospf-tmpl-cost').value = tmpl.cost || 1; document.getElementById('modal-ospf-tmpl-priority').value = tmpl.priority || 128; document.getElementById('modal-ospf-tmpl-passive').checked = !!tmpl.passive; document.getElementById('modal-ospf-tmpl-hello').value = tmpl.hello_interval || 10; document.getElementById('modal-ospf-tmpl-dead').value = tmpl.dead_interval || 40; document.getElementById('modal-ospf-tmpl-auth').value = tmpl.auth || tmpl.auth_type || 'none'; document.getElementById('modal-ospf-tmpl-auth-key').value = tmpl.auth_key || ''; const btnRem = document.getElementById('btn-ospf-tmpl-modal-remove'); if (btnRem) btnRem.style.display = isEdit ? 'block' : 'none'; document.getElementById('ospf-template-modal').classList.remove('hidden'); } function openEditOspfTemplateModal(id) { openOspfTemplateModal(id); } function closeOspfTemplateModal() { document.getElementById('ospf-template-modal').classList.add('hidden'); } async function submitOspfTemplate(e) { if (e) e.preventDefault(); const payload = { id: document.getElementById('modal-ospf-tmpl-id').value, interfaces: document.getElementById('modal-ospf-tmpl-interfaces').value.trim(), interface: document.getElementById('modal-ospf-tmpl-interfaces').value.trim(), area: document.getElementById('modal-ospf-tmpl-area').value, networks: document.getElementById('modal-ospf-tmpl-networks').value.trim(), network: document.getElementById('modal-ospf-tmpl-networks').value.trim(), network_type: document.getElementById('modal-ospf-tmpl-net-type').value, cost: parseInt(document.getElementById('modal-ospf-tmpl-cost').value, 10) || 1, priority: parseInt(document.getElementById('modal-ospf-tmpl-priority').value, 10) || 128, passive: document.getElementById('modal-ospf-tmpl-passive').checked, hello_interval: parseInt(document.getElementById('modal-ospf-tmpl-hello').value, 10) || 10, dead_interval: parseInt(document.getElementById('modal-ospf-tmpl-dead').value, 10) || 40, auth: document.getElementById('modal-ospf-tmpl-auth').value, auth_type: document.getElementById('modal-ospf-tmpl-auth').value, auth_key: document.getElementById('modal-ospf-tmpl-auth-key').value, disabled: !document.getElementById('modal-ospf-tmpl-enabled').checked, comment: document.getElementById('modal-ospf-tmpl-comment').value.trim() }; try { const res = await fetch(`${API_BASE}/routing/ospf/templates`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeOspfTemplateModal(); showToast('Saved', 'Interface template saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save interface template', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function applyOspfTemplateForm() { submitOspfTemplate(null); } function copyOspfTemplate() { document.getElementById('modal-ospf-tmpl-id').value = ''; document.getElementById('ospf-template-modal-title').innerText = 'New...'; showToast('Copied', 'Duplicated as new template', 'info'); } async function deleteCurrentOspfTemplate() { const id = document.getElementById('modal-ospf-tmpl-id').value; if (!id) return; if (!confirm('Delete this interface template?')) return; try { await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }); closeOspfTemplateModal(); showToast('Removed', 'Interface template deleted', 'success'); loadDynamicRouting(); } catch (err) { showToast('Error', err.message, 'error'); } } async function enableSelectedOspfTemplate() { const id = state.selectedOspfRow?.templates; if (!id) return; await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) }); loadDynamicRouting(); } async function disableSelectedOspfTemplate() { const id = state.selectedOspfRow?.templates; if (!id) return; await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) }); loadDynamicRouting(); } async function removeSelectedOspfTemplate() { const id = state.selectedOspfRow?.templates; if (!id) return; if (!confirm('Delete selected interface template?')) return; await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }); loadDynamicRouting(); } async function promptCommentOspfTemplate() { const id = state.selectedOspfRow?.templates; if (!id) return; const tmpl = (state.ospfTemplates || []).find(t => t.id === id); if (!tmpl) return; const comment = prompt('Enter comment for template:', tmpl.comment || ''); if (comment === null) return; tmpl.comment = comment; await fetch(`${API_BASE}/routing/ospf/templates`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tmpl) }); loadDynamicRouting(); } // --- 5. OSPF STATIC NEIGHBOR --- function populateStaticNbrAreaDropdowns() { const select = document.getElementById('modal-ospf-snbr-area'); if (select) { const cur = select.value; select.innerHTML = (state.ospfAreas || []).map(a => ``).join(''); if (select.innerHTML === '') select.innerHTML = ''; if (cur) select.value = cur; } } function openOspfStaticNbrModal(id = null) { const isEdit = !!id; populateStaticNbrAreaDropdowns(); const nbr = (state.ospfStaticNbrs || []).find(n => n.id === id) || { id: '', address: '', area: 'Default', poll_interval: 120, cost: 1, disabled: false, comment: '' }; document.getElementById('ospf-static-nbr-modal-title').innerText = isEdit ? nbr.address : 'New...'; document.getElementById('modal-ospf-snbr-id').value = isEdit ? nbr.id : ''; document.getElementById('modal-ospf-snbr-enabled').checked = !nbr.disabled; document.getElementById('modal-ospf-snbr-comment').value = nbr.comment || ''; document.getElementById('modal-ospf-snbr-address').value = nbr.address || ''; document.getElementById('modal-ospf-snbr-area').value = nbr.area || 'Default'; document.getElementById('modal-ospf-snbr-poll').value = nbr.poll_interval || 120; document.getElementById('modal-ospf-snbr-cost').value = nbr.cost || 1; const btnRem = document.getElementById('btn-ospf-snbr-modal-remove'); if (btnRem) btnRem.style.display = isEdit ? 'block' : 'none'; document.getElementById('ospf-static-nbr-modal').classList.remove('hidden'); } function openEditOspfStaticNbrModal(id) { openOspfStaticNbrModal(id); } function closeOspfStaticNbrModal() { document.getElementById('ospf-static-nbr-modal').classList.add('hidden'); } async function submitOspfStaticNbr(e) { if (e) e.preventDefault(); const payload = { id: document.getElementById('modal-ospf-snbr-id').value, address: document.getElementById('modal-ospf-snbr-address').value.trim(), area: document.getElementById('modal-ospf-snbr-area').value, poll_interval: parseInt(document.getElementById('modal-ospf-snbr-poll').value, 10) || 120, cost: parseInt(document.getElementById('modal-ospf-snbr-cost').value, 10) || 1, disabled: !document.getElementById('modal-ospf-snbr-enabled').checked, comment: document.getElementById('modal-ospf-snbr-comment').value.trim() }; try { const res = await fetch(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { closeOspfStaticNbrModal(); showToast('Saved', 'Static neighbor saved', 'success'); loadDynamicRouting(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to save static neighbor', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function applyOspfStaticNbrForm() { submitOspfStaticNbr(null); } async function deleteCurrentOspfStaticNbr() { const id = document.getElementById('modal-ospf-snbr-id').value; if (!id) return; if (!confirm('Delete this static neighbor?')) return; try { await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}`, { method: 'DELETE' }); closeOspfStaticNbrModal(); showToast('Removed', 'Static neighbor removed', 'success'); loadDynamicRouting(); } catch (err) { showToast('Error', err.message, 'error'); } } async function enableSelectedOspfStaticNbr() { const id = state.selectedOspfRow?.['static-nbrs']; if (!id) return; const n = (state.ospfStaticNbrs || []).find(it => it.id === id); if (!n) return; n.disabled = false; await fetch(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(n) }); loadDynamicRouting(); } async function disableSelectedOspfStaticNbr() { const id = state.selectedOspfRow?.['static-nbrs']; if (!id) return; const n = (state.ospfStaticNbrs || []).find(it => it.id === id); if (!n) return; n.disabled = true; await fetch(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(n) }); loadDynamicRouting(); } async function removeSelectedOspfStaticNbr() { const id = state.selectedOspfRow?.['static-nbrs']; if (!id) return; if (!confirm('Delete selected static neighbor?')) return; await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}`, { method: 'DELETE' }); loadDynamicRouting(); } async function promptCommentOspfStaticNbr() { const id = state.selectedOspfRow?.['static-nbrs']; if (!id) return; const n = (state.ospfStaticNbrs || []).find(it => it.id === id); if (!n) return; const comment = prompt('Enter comment for static neighbor:', n.comment || ''); if (comment === null) return; n.comment = comment; await fetch(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(n) }); loadDynamicRouting(); } // Backward-compat aliases function openAddOspfInterfaceModal() { openOspfTemplateModal(); } function openEditOspfInterfaceModal(id) { openEditOspfTemplateModal(id); } function closeOspfInterfaceModal() { closeOspfTemplateModal(); } function submitOspfInterface(e) { submitOspfTemplate(e); } function deleteOspfInterface(id) { fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(() => loadDynamicRouting()); } function toggleOspfInterfaceState(id, disabled) { fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }).then(() => loadDynamicRouting()); } function openAddOspfAreaModal() { openOspfAreaModal(); } function deleteOspfArea(id) { fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(() => loadDynamicRouting()); } // ============================================================================ // ==================== 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(); setTimeout(initTableSorting, 50); } 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 = 'stp'; 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 || 'stp'; 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); } // ------------------------------------------------------------- // DebTik Routing Filters Management (/routing filter) // ------------------------------------------------------------- if (!state.activeRoutingFilterTab) state.activeRoutingFilterTab = 'rules'; if (!state.selectedFilterRow) state.selectedFilterRow = {}; if (!state.rfSort) state.rfSort = {}; function switchRoutingFilterTab(tab) { state.activeRoutingFilterTab = tab; const tabs = ['rules', 'select-rules', 'community-lists', 'as-lists']; tabs.forEach(t => { const btn = document.getElementById(`tab-rf-${t}`); const content = document.getElementById(`tab-content-rf-${t}`); if (btn) { if (t === tab) { btn.className = 'px-3 py-1 text-xs font-semibold rounded bg-emerald-600/20 text-emerald-300 border border-emerald-500/30 transition shadow-sm'; } else { btn.className = 'px-3 py-1 text-xs font-semibold rounded text-slate-400 hover:text-white hover:bg-slate-800 transition'; } } if (content) { if (t === tab) content.classList.remove('hidden'); else content.classList.add('hidden'); } }); const chainContainer = document.getElementById('rf-chain-filter-container'); if (chainContainer) { if (tab === 'rules' || tab === 'select-rules') chainContainer.classList.remove('hidden'); else chainContainer.classList.add('hidden'); } renderRoutingFilters(); } function selectFilterRow(tab, id) { if (state.selectedFilterRow[tab] === id) { delete state.selectedFilterRow[tab]; } else { state.selectedFilterRow[tab] = id; } if (tab === 'rules') renderRoutingFilterRules(); else if (tab === 'select-rules') renderRoutingFilterSelectRules(); else if (tab === 'community-lists') renderRoutingFilterCommunityLists(); else if (tab === 'as-lists') renderRoutingFilterASLists(); } function openRoutingFilterNewModal() { const tab = state.activeRoutingFilterTab || 'rules'; if (tab === 'rules') openAddRoutingFilterModal(); else if (tab === 'select-rules') openAddSelectRuleModal(); else if (tab === 'community-lists') openAddCommunityListModal(); else if (tab === 'as-lists') openAddASListModal(); } function toggleSelectedRoutingFilter(disabled) { const tab = state.activeRoutingFilterTab || 'rules'; const id = state.selectedFilterRow[tab]; if (!id) { showToast('Select Row', 'Please click on a row to select it first', 'info'); return; } if (tab === 'rules') toggleRoutingFilterState(id, disabled); else if (tab === 'select-rules') toggleSelectRuleState(id, disabled); else if (tab === 'community-lists') toggleCommunityListState(id, disabled); else if (tab === 'as-lists') toggleASListState(id, disabled); } function removeSelectedRoutingFilter() { const tab = state.activeRoutingFilterTab || 'rules'; const id = state.selectedFilterRow[tab]; if (!id) { showToast('Select Row', 'Please click on a row to select it first', 'info'); return; } if (tab === 'rules') deleteRoutingFilter(id); else if (tab === 'select-rules') deleteSelectRule(id); else if (tab === 'community-lists') deleteCommunityList(id); else if (tab === 'as-lists') deleteASList(id); } function searchRoutingFilters(q) { state.rfSearchQuery = (q || '').trim().toLowerCase(); renderRoutingFilters(); } function sortFilterTable(tab, col) { if (!state.rfSort) state.rfSort = {}; if (!state.rfSort[tab]) state.rfSort[tab] = { col: col, asc: true }; if (state.rfSort[tab].col === col) { state.rfSort[tab].asc = !state.rfSort[tab].asc; } else { state.rfSort[tab].col = col; state.rfSort[tab].asc = true; } const asc = state.rfSort[tab].asc; let list = []; if (tab === 'rules') list = state.routingFilters || []; else if (tab === 'select-rules') list = state.routingSelectRules || []; else if (tab === 'community-lists') list = state.routingCommunityLists || []; else if (tab === 'as-lists') list = state.routingASLists || []; list.sort((a, b) => { let valA = a[col] ?? ''; let valB = b[col] ?? ''; if (col === 'flags') { valA = a.disabled ? 'X' : 'A'; valB = b.disabled ? 'X' : 'A'; } if (typeof valA === 'number' && typeof valB === 'number') { return asc ? valA - valB : valB - valA; } return asc ? String(valA).localeCompare(String(valB)) : String(valB).localeCompare(String(valA)); }); const allCols = ['flags', 'rule_order', 'chain', 'rule', 'name', 'communities', 'range', 'regexp', 'comment']; allCols.forEach(c => { const el = document.getElementById(`sort-icon-${tab}-${c}`); if (el) el.innerText = (c === col) ? (asc ? '▲' : '▼') : ''; }); if (tab === 'rules') renderRoutingFilterRules(); else if (tab === 'select-rules') renderRoutingFilterSelectRules(); else if (tab === 'community-lists') renderRoutingFilterCommunityLists(); else if (tab === 'as-lists') renderRoutingFilterASLists(); } function renderRoutingFilters() { const navBadge = document.getElementById('badge-filters-count'); const allRules = state.routingFilters || []; if (navBadge) navBadge.innerText = allRules.length; // Populate chain filter select options and datalist const chainSelect = document.getElementById('routing-filter-chain-select'); const chainDatalist = document.getElementById('dl-routing-chains'); const uniqueChains = Array.from(new Set(allRules.map(r => r.chain).filter(Boolean))).sort(); if (chainSelect) { const currentVal = state.filterChainSelected || 'all'; chainSelect.innerHTML = `` + uniqueChains.map(ch => { const count = allRules.filter(r => r.chain === ch).length; return ``; }).join(''); chainSelect.value = currentVal; } if (chainDatalist) { chainDatalist.innerHTML = uniqueChains.map(ch => `'; 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'); const sig = clients.map(c => `${c.id}:${c.status}:${c.ip_address}:${c.gateway}:${c.enabled}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; 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.filter(i => supportsDHCPClient(i)).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'); } } // ==================== DHCP SERVER (/ip dhcp-server) ==================== async function loadDHCPServer(populate = true) { try { const [sumRes, srvRes, netRes, leaseRes, optRes] = await Promise.all([ fetch(`${API_BASE}/dhcp-server/summary`), fetch(`${API_BASE}/dhcp-server/servers`), fetch(`${API_BASE}/dhcp-server/networks`), fetch(`${API_BASE}/dhcp-server/leases`), fetch(`${API_BASE}/dhcp-server/options`) ]); if (sumRes.ok) state.dhcpServerSummary = await sumRes.json(); if (srvRes.ok) state.dhcpServers = await srvRes.json(); if (netRes.ok) state.dhcpNetworks = await netRes.json(); if (leaseRes.ok) state.dhcpLeases = await leaseRes.json(); if (optRes.ok) state.dhcpOptions = await optRes.json(); const sum = state.dhcpServerSummary || {}; const srvs = state.dhcpServers || []; const nets = state.dhcpNetworks || []; const leases = state.dhcpLeases || []; const opts = state.dhcpOptions || []; // KPI Cards const statActive = document.getElementById('dhcp-server-stat-active'); if (statActive) { const activeCount = srvs.filter(s => s.running).length; statActive.innerText = `${activeCount} / ${srvs.length}`; } const statLeases = document.getElementById('dhcp-server-stat-leases'); if (statLeases) { statLeases.innerText = leases.length; } const statLeasesSub = document.getElementById('dhcp-server-stat-leases-sub'); if (statLeasesSub) { const dyn = leases.filter(l => l.dynamic).length; const st = leases.filter(l => !l.dynamic).length; statLeasesSub.innerText = `${dyn} dynamic, ${st} static`; } const statNetworks = document.getElementById('dhcp-server-stat-networks'); if (statNetworks) { statNetworks.innerText = nets.length; } // Subtab Badges const bSrv = document.getElementById('dhcp-server-tab-badge-servers'); if (bSrv) bSrv.innerText = srvs.length; const bNet = document.getElementById('dhcp-server-tab-badge-networks'); if (bNet) bNet.innerText = nets.length; const bLease = document.getElementById('dhcp-server-tab-badge-leases'); if (bLease) bLease.innerText = leases.length; const bOpt = document.getElementById('dhcp-server-tab-badge-options'); if (bOpt) bOpt.innerText = opts.length; // Sidebar Badge const sideBadge = document.getElementById('badge-dhcp-server-count'); if (sideBadge) sideBadge.innerText = leases.length || srvs.length; renderCurrentDHCPServerTab(); } catch (err) { console.error('Failed to load DHCP Server:', err); } } function switchDHCPServerTab(tab) { state.dhcpServerTab = tab; const tabs = ['servers', 'networks', 'leases', 'options']; tabs.forEach(t => { const panel = document.getElementById(`dhcp-server-subtab-${t}`); const btn = document.getElementById(`dhcp-server-tab-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('dhcp-server-primary-add-btn'); if (addBtn) { if (tab === 'servers') addBtn.innerHTML = '+ Add Server'; else if (tab === 'networks') addBtn.innerHTML = '+ Add Network'; else if (tab === 'leases') addBtn.innerHTML = '+ Add Static Lease'; else if (tab === 'options') addBtn.innerHTML = '+ Add Option'; } renderCurrentDHCPServerTab(); } function handleDHCPServerPrimaryAdd() { const tab = state.dhcpServerTab || 'servers'; if (tab === 'servers') openAddDHCPServerModal(); else if (tab === 'networks') openAddDHCPNetworkModal(); else if (tab === 'leases') openAddDHCPStaticLeaseModal(); else if (tab === 'options') openAddDHCPOptionModal(); } function renderCurrentDHCPServerTab() { const tab = state.dhcpServerTab || 'servers'; if (tab === 'servers') renderDHCPServers(); else if (tab === 'networks') renderDHCPNetworks(); else if (tab === 'leases') renderDHCPLeases(); else if (tab === 'options') renderDHCPOptions(); } function renderDHCPServers() { const tbody = document.getElementById('dhcp-server-tbody'); const empty = document.getElementById('dhcp-server-empty'); if (!tbody) return; const servers = state.dhcpServers || []; if (servers.length === 0) { tbody.innerHTML = ''; empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); const sig = servers.map(s => `${s.id}:${s.running}:${s.disabled}:${s.active_leases}:${s.interface}`).join(';'); if (tbody.dataset.sig === sig) return; tbody.dataset.sig = sig; tbody.innerHTML = ''; servers.forEach(s => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${s.disabled ? 'opacity-50' : ''}`; tr.innerHTML = ` ${renderMikroTikFlag(s.running, s.disabled)} ${escapeHtml(s.name)} ${escapeHtml(s.interface)} ${s.network ? `${escapeHtml(s.network)}` : 'Unassigned'} ${escapeHtml(s.address_pool)} ${escapeHtml(s.lease_time || '12h')} ${s.authoritative ? 'yes' : 'no'} ${s.active_leases || 0} ${escapeHtml(s.comment || '-')} `; tbody.appendChild(tr); }); } function renderDHCPNetworks() { const tbody = document.getElementById('dhcp-networks-tbody'); const empty = document.getElementById('dhcp-networks-empty'); if (!tbody) return; tbody.innerHTML = ''; const networks = state.dhcpNetworks || []; if (networks.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); networks.forEach(n => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; const dnsStr = (n.dns_servers || []).join(', ') || '-'; const optionsChips = (n.dhcp_options || []).map(optId => { const opt = (state.dhcpOptions || []).find(o => o.id === optId); if (!opt) return ''; return `#${opt.code} ${escapeHtml(opt.name)}`; }).filter(Boolean).join(''); tr.innerHTML = ` ${escapeHtml(n.address)} ${escapeHtml(n.gateway || '-')} ${escapeHtml(n.netmask || '-')} ${escapeHtml(dnsStr)} ${escapeHtml(n.domain || '-')} ${optionsChips || '-'} ${escapeHtml(n.comment || '-')} `; tbody.appendChild(tr); }); } function renderDHCPLeases() { const tbody = document.getElementById('dhcp-leases-tbody'); const empty = document.getElementById('dhcp-leases-empty'); if (!tbody) return; tbody.innerHTML = ''; const leases = state.dhcpLeases || []; if (leases.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); leases.forEach(l => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${l.disabled ? 'opacity-50' : ''}`; const flag = l.dynamic ? 'D' : 'S'; const statusBadge = l.status === 'bound' ? 'bound' : l.status === 'static' ? 'static' : 'waiting'; const makeStaticBtn = l.dynamic ? `` : ``; tr.innerHTML = ` ${flag} ${escapeHtml(l.address)} ${escapeHtml(l.mac_address)} ${escapeHtml(l.hostname || '*')} ${escapeHtml(l.server || 'all')} ${escapeHtml(l.expires_in || 'never')} ${statusBadge} ${escapeHtml(l.comment || '-')} ${makeStaticBtn} `; tbody.appendChild(tr); }); } function renderDHCPOptions() { const tbody = document.getElementById('dhcp-options-tbody'); const empty = document.getElementById('dhcp-options-empty'); if (!tbody) return; tbody.innerHTML = ''; const options = state.dhcpOptions || []; if (options.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); options.forEach(o => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${escapeHtml(o.name)} ${o.code} ${escapeHtml(o.value)} ${escapeHtml(o.comment || '-')} `; tbody.appendChild(tr); }); } // ==================== MODALS & ACTIONS: SERVER ==================== function populateDHCPNetworkSelect(elemId, selectedVal) { const sel = document.getElementById(elemId); if (!sel) return; sel.innerHTML = ''; const networks = state.dhcpNetworks || []; networks.forEach(n => { const opt = document.createElement('option'); opt.value = n.address; opt.innerText = `${n.address} (Gateway: ${n.gateway || 'None'}${n.comment ? ' - ' + n.comment : ''})`; if (selectedVal && (selectedVal === n.address || selectedVal === n.id)) { opt.selected = true; } sel.appendChild(opt); }); } function onDHCPServerNetworkChange() { const netSel = document.getElementById('dhcp-server-modal-network'); if (!netSel) return; const netVal = netSel.value; if (!netVal) return; const poolInput = document.getElementById('dhcp-server-modal-pool'); if (!poolInput) return; // Auto-calculate suggested address pool based on subnet CIDR const parts = netVal.split('/'); const ip = parts[0]; const cidr = parseInt(parts[1] || '24', 10); if (cidr === 24) { const octets = ip.split('.'); if (octets.length === 4) { const base = `${octets[0]}.${octets[1]}.${octets[2]}`; poolInput.value = `${base}.10-${base}.254`; } } } function openAddDHCPServerModal() { document.getElementById('dhcp-server-modal-title').innerText = 'Add DHCP Server'; document.getElementById('dhcp-server-modal-id').value = ''; document.getElementById('dhcp-server-modal-name').value = `dhcp${(state.dhcpServers || []).length + 1}`; document.getElementById('dhcp-server-modal-pool').value = '192.168.88.10-192.168.88.254'; document.getElementById('dhcp-server-modal-leasetime').value = '12h'; document.getElementById('dhcp-server-modal-comment').value = ''; document.getElementById('dhcp-server-modal-authoritative').checked = true; document.getElementById('dhcp-server-modal-disabled').checked = false; populateDHCPIfaceSelect('dhcp-server-modal-iface', ''); const networks = state.dhcpNetworks || []; const defaultNet = networks.length > 0 ? networks[0].address : ''; populateDHCPNetworkSelect('dhcp-server-modal-network', defaultNet); if (defaultNet) { onDHCPServerNetworkChange(); } document.getElementById('dhcp-server-instance-modal').classList.remove('hidden'); } function openEditDHCPServerModal(id) { const s = (state.dhcpServers || []).find(item => item.id === id); if (!s) return; document.getElementById('dhcp-server-modal-title').innerText = `Edit DHCP Server (${s.name})`; document.getElementById('dhcp-server-modal-id').value = s.id; document.getElementById('dhcp-server-modal-name').value = s.name; document.getElementById('dhcp-server-modal-pool').value = s.address_pool; document.getElementById('dhcp-server-modal-leasetime').value = s.lease_time || '12h'; document.getElementById('dhcp-server-modal-comment').value = s.comment || ''; document.getElementById('dhcp-server-modal-authoritative').checked = s.authoritative !== false; document.getElementById('dhcp-server-modal-disabled').checked = !!s.disabled; populateDHCPIfaceSelect('dhcp-server-modal-iface', s.interface); populateDHCPNetworkSelect('dhcp-server-modal-network', s.network || ''); document.getElementById('dhcp-server-instance-modal').classList.remove('hidden'); } function closeDHCPServerModal() { document.getElementById('dhcp-server-instance-modal').classList.add('hidden'); } async function submitSaveDHCPServer(e) { e.preventDefault(); const id = document.getElementById('dhcp-server-modal-id').value; const name = document.getElementById('dhcp-server-modal-name').value.trim(); const iface = document.getElementById('dhcp-server-modal-iface').value; const network = document.getElementById('dhcp-server-modal-network').value.trim(); const pool = document.getElementById('dhcp-server-modal-pool').value.trim(); const leaseTime = document.getElementById('dhcp-server-modal-leasetime').value.trim() || '12h'; const comment = document.getElementById('dhcp-server-modal-comment').value.trim(); const authoritative = document.getElementById('dhcp-server-modal-authoritative').checked; const disabled = document.getElementById('dhcp-server-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/dhcp-server/servers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, interface: iface, network, address_pool: pool, lease_time: leaseTime, authoritative, disabled, comment }) }); if (res.ok) { showToast('DHCP Server Saved', `Server ${name} configured successfully`, 'success'); closeDHCPServerModal(); loadDHCPServer(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleDHCPServerState(id, disabled) { try { const res = await fetch(`${API_BASE}/dhcp-server/servers/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast('State Updated', disabled ? 'Server disabled' : 'Server enabled', 'info'); loadDHCPServer(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteDHCPServer(id, name) { if (!confirm(`Are you sure you want to delete DHCP Server "${name}"?`)) return; try { const res = await fetch(`${API_BASE}/dhcp-server/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `DHCP Server ${name} removed`, 'success'); loadDHCPServer(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ==================== MODALS & ACTIONS: NETWORKS ==================== function openAddDHCPNetworkModal() { document.getElementById('dhcp-network-modal-title').innerText = 'Add DHCP Network'; document.getElementById('dhcp-network-modal-id').value = ''; document.getElementById('dhcp-network-modal-address').value = '192.168.88.0/24'; document.getElementById('dhcp-network-modal-gateway').value = '192.168.88.1'; document.getElementById('dhcp-network-modal-dns').value = '192.168.88.1, 1.1.1.1, 8.8.8.8'; document.getElementById('dhcp-network-modal-domain').value = 'lan'; document.getElementById('dhcp-network-modal-netmask').value = ''; document.getElementById('dhcp-network-modal-comment').value = ''; renderDHCPNetworkOptionsCheckboxes([]); document.getElementById('dhcp-network-modal').classList.remove('hidden'); } function openEditDHCPNetworkModal(id) { const n = (state.dhcpNetworks || []).find(item => item.id === id); if (!n) return; document.getElementById('dhcp-network-modal-title').innerText = `Edit DHCP Network (${n.address})`; document.getElementById('dhcp-network-modal-id').value = n.id; document.getElementById('dhcp-network-modal-address').value = n.address; document.getElementById('dhcp-network-modal-gateway').value = n.gateway || ''; document.getElementById('dhcp-network-modal-dns').value = (n.dns_servers || []).join(', '); document.getElementById('dhcp-network-modal-domain').value = n.domain || ''; document.getElementById('dhcp-network-modal-netmask').value = n.netmask || ''; document.getElementById('dhcp-network-modal-comment').value = n.comment || ''; renderDHCPNetworkOptionsCheckboxes(n.dhcp_options || []); document.getElementById('dhcp-network-modal').classList.remove('hidden'); } function renderDHCPNetworkOptionsCheckboxes(selectedIds) { const container = document.getElementById('dhcp-network-modal-options-list'); if (!container) return; container.innerHTML = ''; const opts = state.dhcpOptions || []; if (opts.length === 0) { container.innerHTML = 'No custom options configured. Add some in the "Options" tab.'; return; } opts.forEach(o => { const isChecked = selectedIds.includes(o.id); const label = document.createElement('label'); label.className = 'flex items-center space-x-2 text-slate-300 hover:text-white cursor-pointer'; label.innerHTML = ` ${escapeHtml(o.name)} #${o.code} `; container.appendChild(label); }); } function closeDHCPNetworkModal() { document.getElementById('dhcp-network-modal').classList.add('hidden'); } async function submitSaveDHCPNetwork(e) { e.preventDefault(); const id = document.getElementById('dhcp-network-modal-id').value; const address = document.getElementById('dhcp-network-modal-address').value.trim(); const gateway = document.getElementById('dhcp-network-modal-gateway').value.trim(); const dnsStr = document.getElementById('dhcp-network-modal-dns').value.trim(); const domain = document.getElementById('dhcp-network-modal-domain').value.trim(); const netmask = document.getElementById('dhcp-network-modal-netmask').value.trim(); const comment = document.getElementById('dhcp-network-modal-comment').value.trim(); const selectedOpts = []; document.querySelectorAll('input[name="dhcp-net-opt"]:checked').forEach(cb => selectedOpts.push(cb.value)); const dnsServers = dnsStr ? dnsStr.split(',').map(s => s.trim()).filter(Boolean) : []; try { const res = await fetch(`${API_BASE}/dhcp-server/networks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, address, gateway, netmask, dns_servers: dnsServers, domain, dhcp_options: selectedOpts, comment }) }); if (res.ok) { showToast('Network Saved', `Subnet ${address} configured successfully`, 'success'); closeDHCPNetworkModal(); loadDHCPServer(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteDHCPNetwork(id, addr) { if (!confirm(`Are you sure you want to delete DHCP Network "${addr}"?`)) return; try { const res = await fetch(`${API_BASE}/dhcp-server/networks/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `Network ${addr} removed`, 'success'); loadDHCPServer(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ==================== MODALS & ACTIONS: LEASES ==================== function openAddDHCPStaticLeaseModal() { document.getElementById('dhcp-static-lease-modal-title').innerText = 'Add Static Lease Reservation'; document.getElementById('dhcp-static-lease-modal-id').value = ''; document.getElementById('dhcp-static-lease-modal-mac').value = ''; document.getElementById('dhcp-static-lease-modal-ip').value = ''; document.getElementById('dhcp-static-lease-modal-hostname').value = ''; document.getElementById('dhcp-static-lease-modal-server').value = 'all'; document.getElementById('dhcp-static-lease-modal-comment').value = ''; document.getElementById('dhcp-static-lease-modal-disabled').checked = false; document.getElementById('dhcp-static-lease-modal').classList.remove('hidden'); } function closeDHCPStaticLeaseModal() { document.getElementById('dhcp-static-lease-modal').classList.add('hidden'); } async function submitSaveDHCPStaticLease(e) { e.preventDefault(); const id = document.getElementById('dhcp-static-lease-modal-id').value; const mac = document.getElementById('dhcp-static-lease-modal-mac').value.trim(); const ip = document.getElementById('dhcp-static-lease-modal-ip').value.trim(); const hostname = document.getElementById('dhcp-static-lease-modal-hostname').value.trim(); const server = document.getElementById('dhcp-static-lease-modal-server').value.trim() || 'all'; const comment = document.getElementById('dhcp-static-lease-modal-comment').value.trim(); const disabled = document.getElementById('dhcp-static-lease-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/dhcp-server/leases/static`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, mac_address: mac, address: ip, hostname, server, comment, disabled }) }); if (res.ok) { showToast('Static Lease Saved', `IP ${ip} reserved for ${mac}`, 'success'); closeDHCPStaticLeaseModal(); loadDHCPServer(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteDHCPStaticLease(id) { if (!confirm('Are you sure you want to remove this static reservation?')) return; try { const res = await fetch(`${API_BASE}/dhcp-server/leases/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Static lease removed', 'success'); loadDHCPServer(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function makeDHCPLeaseStatic(mac, ip, hostname, comment) { try { const res = await fetch(`${API_BASE}/dhcp-server/leases/make-static`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mac_address: mac, address: ip, hostname, comment: comment || 'Converted to static' }) }); if (res.ok) { showToast('Lease Made Static', `IP ${ip} permanently reserved for ${mac}`, 'success'); loadDHCPServer(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ==================== MODALS & ACTIONS: OPTIONS ==================== function openAddDHCPOptionModal() { document.getElementById('dhcp-option-modal-title').innerText = 'Add DHCP Option'; document.getElementById('dhcp-option-modal-id').value = ''; document.getElementById('dhcp-option-modal-name').value = ''; document.getElementById('dhcp-option-modal-code').value = ''; document.getElementById('dhcp-option-modal-value').value = ''; document.getElementById('dhcp-option-modal-comment').value = ''; document.getElementById('dhcp-option-modal').classList.remove('hidden'); } function openEditDHCPOptionModal(id) { const o = (state.dhcpOptions || []).find(item => item.id === id); if (!o) return; document.getElementById('dhcp-option-modal-title').innerText = `Edit DHCP Option (${o.name})`; document.getElementById('dhcp-option-modal-id').value = o.id; document.getElementById('dhcp-option-modal-name').value = o.name; document.getElementById('dhcp-option-modal-code').value = o.code; document.getElementById('dhcp-option-modal-value').value = o.value; document.getElementById('dhcp-option-modal-comment').value = o.comment || ''; document.getElementById('dhcp-option-modal').classList.remove('hidden'); } function closeDHCPOptionModal() { document.getElementById('dhcp-option-modal').classList.add('hidden'); } async function submitSaveDHCPOption(e) { e.preventDefault(); const id = document.getElementById('dhcp-option-modal-id').value; const name = document.getElementById('dhcp-option-modal-name').value.trim(); const code = parseInt(document.getElementById('dhcp-option-modal-code').value); const value = document.getElementById('dhcp-option-modal-value').value.trim(); const comment = document.getElementById('dhcp-option-modal-comment').value.trim(); try { const res = await fetch(`${API_BASE}/dhcp-server/options`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, code, value, comment }) }); if (res.ok) { showToast('Option Saved', `DHCP option ${name} (#${code}) configured`, 'success'); closeDHCPOptionModal(); loadDHCPServer(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteDHCPOption(id, name) { if (!confirm(`Are you sure you want to delete DHCP Option "${name}"?`)) return; try { const res = await fetch(`${API_BASE}/dhcp-server/options/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `Option ${name} removed`, 'success'); loadDHCPServer(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ==================== WIZARD: DHCP SETUP (MikroTik style) ==================== function openDHCPServerSetupModal() { populateDHCPIfaceSelect('dhcp-wizard-iface', ''); onDHCPWizardIfaceChange(); document.getElementById('dhcp-setup-wizard-modal').classList.remove('hidden'); } function closeDHCPServerSetupModal() { document.getElementById('dhcp-setup-wizard-modal').classList.add('hidden'); } function onDHCPWizardIfaceChange() { const ifaceName = document.getElementById('dhcp-wizard-iface').value; const iface = (state.interfaces || []).find(i => i.name === ifaceName); let ip = '192.168.88.1'; let prefix = '24'; if (iface && iface.ip_address) { const parts = iface.ip_address.split('/'); ip = parts[0]; if (parts.length > 1) prefix = parts[1]; } // Calculate subnet base (e.g. 192.168.88.0/24) const ipParts = ip.split('.'); if (ipParts.length === 4) { const baseSpace = `${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.0/${prefix}`; const pool = `${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.10-${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.254`; document.getElementById('dhcp-wizard-space').value = baseSpace; document.getElementById('dhcp-wizard-gateway').value = ip; document.getElementById('dhcp-wizard-pool').value = pool; document.getElementById('dhcp-wizard-dns').value = `${ip}, 1.1.1.1, 8.8.8.8`; document.getElementById('dhcp-wizard-ntp').value = `${ip}`; } } async function submitDHCPServerSetupWizard(e) { e.preventDefault(); const iface = document.getElementById('dhcp-wizard-iface').value; const space = document.getElementById('dhcp-wizard-space').value.trim(); const gw = document.getElementById('dhcp-wizard-gateway').value.trim(); const pool = document.getElementById('dhcp-wizard-pool').value.trim(); const dnsStr = document.getElementById('dhcp-wizard-dns').value.trim(); const leaseTime = document.getElementById('dhcp-wizard-leasetime').value.trim() || '12h'; const ntpStr = document.getElementById('dhcp-wizard-ntp').value.trim(); const tftp = document.getElementById('dhcp-wizard-tftp').value.trim(); const dnsServers = dnsStr ? dnsStr.split(',').map(s => s.trim()).filter(Boolean) : []; const ntpServers = ntpStr ? ntpStr.split(',').map(s => s.trim()).filter(Boolean) : []; try { const res = await fetch(`${API_BASE}/dhcp-server/setup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, address_space: space, gateway: gw, address_pool: pool, dns_servers: dnsServers, lease_time: leaseTime, ntp_servers: ntpServers, next_server: tftp }) }); if (res.ok) { showToast('DHCP Setup Complete', `DHCP Server setup finished on ${iface}`, 'success'); closeDHCPServerSetupModal(); loadDHCPServer(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function populateDHCPIfaceSelect(elemId, selectedVal) { const sel = document.getElementById(elemId); if (!sel) return; sel.innerHTML = ''; const ifaces = state.interfaces || []; ifaces.forEach(i => { if (i.name === 'lo') return; const opt = document.createElement('option'); opt.value = i.name; const ipStr = i.ip_address ? ` (${i.ip_address})` : ''; opt.textContent = `${i.name}${ipStr} [${i.role || i.type || 'interface'}]`; if (i.name === selectedVal) opt.selected = true; sel.appendChild(opt); }); } // ==================== UNIVERSAL TABLE ORDERING / SORTING (WINBOX / WEBFIG STYLE) ==================== function initTableSorting() { document.querySelectorAll('table').forEach(setupSortableTable); } function setupSortableTable(table) { const thead = table.querySelector('thead'); const tbody = table.querySelector('tbody'); if (!thead || !tbody) return; const headerRows = thead.querySelectorAll('tr'); if (headerRows.length === 0) return; const ths = headerRows[headerRows.length - 1].querySelectorAll('th'); ths.forEach((th, colIdx) => { if (th._sortBound) return; th._sortBound = true; const rawText = th.innerText.trim().toLowerCase(); const isActionsCol = rawText === 'actions' || (rawText === 'action' && th.classList.contains('text-right')); const isCheckboxCol = !!th.querySelector('input[type="checkbox"]'); const isExpandCol = (th.classList.contains('w-8') || th.classList.contains('w-6')) && !rawText; const isExplicitNoSort = th.classList.contains('no-sort') || th.getAttribute('data-sortable') === 'false'; if (isActionsCol || isCheckboxCol || isExpandCol || isExplicitNoSort) { return; } th.classList.add('table-sortable', 'group'); if (!th.title) th.title = 'Click to sort (Ascending / Descending / Default)'; let icon = th.querySelector('.table-sort-icon'); if (!icon) { icon = document.createElement('span'); icon.className = 'table-sort-icon opacity-0 group-hover:opacity-50 text-slate-400'; icon.innerHTML = '⇅'; th.appendChild(icon); } th.addEventListener('click', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.closest('button, input')) return; handleHeaderClick(table, th, colIdx); }); }); if (!tbody._sortObserved) { tbody._sortObserved = true; let sortDebounceTimer = null; const observer = new MutationObserver((mutations) => { if (tbody._isSorting) return; const hasChildChanges = mutations.some(m => m.type === 'childList' && m.addedNodes.length > 0); if (!hasChildChanges) return; clearTimeout(sortDebounceTimer); sortDebounceTimer = setTimeout(() => { if (tbody._isSorting) return; if (table._activeSort && table._activeSort.dir !== 'none') { applyTableSort(table, table._activeSort.colIdx, table._activeSort.dir, false); } else { tagOriginalRowOrder(tbody); } }, 50); }); tbody._sortObserver = observer; observer.observe(tbody, { childList: true }); } tagOriginalRowOrder(tbody); } function tagOriginalRowOrder(tbody) { let idx = 0; Array.from(tbody.children).forEach(tr => { if (!tr.querySelector('td[colspan]')) { if (!tr.hasAttribute('data-orig-idx')) { tr.setAttribute('data-orig-idx', idx++); } } }); } function handleHeaderClick(table, th, colIdx) { const currentSort = table._activeSort || { colIdx: -1, dir: 'none' }; let newDir = 'asc'; if (currentSort.colIdx === colIdx) { if (currentSort.dir === 'asc') newDir = 'desc'; else if (currentSort.dir === 'desc') newDir = 'none'; else newDir = 'asc'; } else { newDir = 'asc'; } table._activeSort = { colIdx, dir: newDir }; updateHeaderIcons(table, colIdx, newDir); applyTableSort(table, colIdx, newDir, true); } function updateHeaderIcons(table, activeColIdx, dir) { const thead = table.querySelector('thead'); if (!thead) return; const ths = thead.querySelectorAll('th'); ths.forEach((th, idx) => { const icon = th.querySelector('.table-sort-icon'); if (!icon) return; if (idx === activeColIdx && dir !== 'none') { icon.innerHTML = dir === 'asc' ? '▲' : '▼'; icon.className = 'table-sort-icon text-cyan-400 font-bold opacity-100'; th.classList.add('table-sorted-active'); } else { icon.innerHTML = '⇅'; icon.className = 'table-sort-icon opacity-0 group-hover:opacity-50 text-slate-400'; th.classList.remove('table-sorted-active'); } }); } function applyTableSort(table, colIdx, dir, userInitiated) { const tbody = table.querySelector('tbody'); if (!tbody || tbody.children.length <= 1) return; tbody._isSorting = true; tagOriginalRowOrder(tbody); // Group parent rows and any attached drawer/sub-rows const groups = []; let currentGroup = null; Array.from(tbody.children).forEach(tr => { if (tr.querySelector('td[colspan]')) { if (currentGroup) { currentGroup.subRows.push(tr); } else { groups.push({ mainRow: tr, subRows: [] }); } } else { currentGroup = { mainRow: tr, subRows: [] }; groups.push(currentGroup); } }); if (dir === 'none') { groups.sort((a, b) => { const idxA = parseInt(a.mainRow.getAttribute('data-orig-idx') || '0', 10); const idxB = parseInt(b.mainRow.getAttribute('data-orig-idx') || '0', 10); return idxA - idxB; }); } else { groups.sort((a, b) => { const cellA = a.mainRow.children[colIdx]; const cellB = b.mainRow.children[colIdx]; const res = compareTableCells(cellA, cellB); return dir === 'asc' ? res : -res; }); } // Check if rows are already in correct order to avoid modifying DOM let alreadyOrdered = true; let childIdx = 0; for (const g of groups) { if (tbody.children[childIdx] !== g.mainRow) { alreadyOrdered = false; break; } childIdx++; for (const sub of g.subRows) { if (tbody.children[childIdx] !== sub) { alreadyOrdered = false; break; } childIdx++; } } if (alreadyOrdered) { tbody._isSorting = false; return; } // Disconnect observer while re-ordering to prevent recursion and thrashing if (tbody._sortObserver) { tbody._sortObserver.disconnect(); } // Re-append rows in sorted order groups.forEach(g => { tbody.appendChild(g.mainRow); g.subRows.forEach(sub => tbody.appendChild(sub)); }); // Drain any queued mutations and re-connect observer if (tbody._sortObserver) { tbody._sortObserver.takeRecords(); tbody._sortObserver.observe(tbody, { childList: true }); } tbody._isSorting = false; } function getCellRawText(cell) { if (!cell) return ''; if (cell.hasAttribute('data-sort-value')) return cell.getAttribute('data-sort-value'); const input = cell.querySelector('input, select'); if (input) { if (input.type === 'checkbox') return input.checked ? '1' : '0'; return input.value; } return cell.innerText.trim(); } function parseTrafficToNumber(str) { const m = str.match(/^([\d.,]+)\s*([KMGTkmgt]?[iI]?[Bb](?:ps|\/s)?)/); if (!m) return null; const num = parseFloat(m[1].replace(/,/g, '')); if (isNaN(num)) return null; const unit = m[2].toUpperCase(); let mult = 1; if (unit.startsWith('K')) mult = 1024; else if (unit.startsWith('M')) mult = 1024 * 1024; else if (unit.startsWith('G')) mult = 1024 * 1024 * 1024; else if (unit.startsWith('T')) mult = 1024 * 1024 * 1024 * 1024; return num * mult; } function parseIpToNumber(str) { const clean = str.replace(/^[!~]/, '').trim(); const m = clean.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?/); if (!m) return null; const o1 = parseInt(m[1], 10); const o2 = parseInt(m[2], 10); const o3 = parseInt(m[3], 10); const o4 = parseInt(m[4], 10); if (o1 > 255 || o2 > 255 || o3 > 255 || o4 > 255) return null; return ((o1 * 256 + o2) * 256 + o3) * 256 + o4; } function parseCompoundPackets(str) { const m = str.match(/^([\d,]+)p/); if (!m) return null; return parseInt(m[1].replace(/,/g, ''), 10); } function compareTableCells(cellA, cellB) { const a = getCellRawText(cellA); const b = getCellRawText(cellB); if (a === '' && b !== '') return 1; if (b === '' && a !== '') return -1; if (a === b) return 0; // 1. IP Addresses / CIDR const ipA = parseIpToNumber(a); const ipB = parseIpToNumber(b); if (ipA !== null && ipB !== null) return ipA - ipB; // 2. Packet counts (e.g. "1,250p / 45 KB") const pktA = parseCompoundPackets(a); const pktB = parseCompoundPackets(b); if (pktA !== null && pktB !== null) return pktA - pktB; // 3. Traffic / Throughput / Bytes (e.g. "1.2 MB", "500 KiB", "10 Gbps") const trafA = parseTrafficToNumber(a); const trafB = parseTrafficToNumber(b); if (trafA !== null && trafB !== null) return trafA - trafB; // 4. Numbers with '#' or formatted with commas (e.g. "#1", "24", "1,200") const cleanNumA = a.replace(/^[#\s]+/, '').replace(/,/g, '').trim(); const cleanNumB = b.replace(/^[#\s]+/, '').replace(/,/g, '').trim(); const numA = Number(cleanNumA); const numB = Number(cleanNumB); if (!isNaN(numA) && !isNaN(numB) && cleanNumA !== '' && cleanNumB !== '') { return numA - numB; } // 5. Natural case-insensitive string compare return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); } // ==================== VRRP HIGH AVAILABILITY CONTROLLER ==================== async function loadVRRP(showToastNotification = false) { try { const [instRes, sumRes] = await Promise.all([ fetch(`${API_BASE}/vrrp/instances`), fetch(`${API_BASE}/vrrp/summary`) ]); if (instRes.ok) { state.vrrpInstances = await instRes.json(); renderVRRP(); } if (sumRes.ok) { state.vrrpSummary = await sumRes.json(); renderVRRPSummary(); } if (showToastNotification) { showToast('Refreshed', 'VRRP telemetry refreshed', 'info'); } } catch (err) { console.error('Failed to load VRRP:', err); } } function renderVRRPSummary() { const s = state.vrrpSummary || { total_instances: 0, master_instances: 0, backup_instances: 0 }; const elTotal = document.getElementById('vrrp-stat-total'); if (elTotal) elTotal.innerText = s.total_instances || 0; const elMaster = document.getElementById('vrrp-stat-master'); if (elMaster) elMaster.innerText = s.master_instances || 0; const elBackup = document.getElementById('vrrp-stat-backup'); if (elBackup) elBackup.innerText = s.backup_instances || 0; const bCount = document.getElementById('badge-vrrp-count'); if (bCount) bCount.innerText = s.total_instances || 0; const dot = document.getElementById('badge-vrrp-dot'); if (dot) { if (s.master_instances > 0) { dot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse'; } else if (s.backup_instances > 0) { dot.className = 'w-2 h-2 rounded-full bg-sky-400'; } else { dot.className = 'w-2 h-2 rounded-full bg-slate-600'; } } } function renderVRRP() { const tbody = document.getElementById('vrrp-table-tbody'); const empty = document.getElementById('vrrp-table-empty'); if (!tbody) return; tbody.innerHTML = ''; const instances = state.vrrpInstances || []; if (instances.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); instances.forEach(inst => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${inst.disabled ? 'opacity-50' : ''}`; // Render Flag: M (Master), B (Backup), F (Fault), X (Disabled) let flagHtml = `-`; if (inst.disabled) { flagHtml = `X`; } else if (inst.state === 'MASTER') { flagHtml = `M`; } else if (inst.state === 'BACKUP') { flagHtml = `B`; } else if (inst.state === 'FAULT') { flagHtml = `F`; } // Render State Badge let stateBadge = `DISABLED`; if (!inst.disabled) { if (inst.state === 'MASTER') { stateBadge = `MASTER`; } else if (inst.state === 'BACKUP') { stateBadge = `BACKUP`; } else if (inst.state === 'FAULT') { stateBadge = `FAULT`; } } const vipsStr = (inst.virtual_ips || []).join(', ') || '-'; tr.innerHTML = ` ${flagHtml} ${escapeHtml(inst.name)} ${escapeHtml(inst.interface)} ${inst.vrid} ${inst.priority} ${escapeHtml(vipsStr)} ${inst.preempt ? 'yes' : 'no'} ${escapeHtml(inst.virtual_mac || '-')} ${stateBadge} ${escapeHtml(inst.comment || '-')} `; tbody.appendChild(tr); }); } function populateVRRPIfaceSelect(elemId, selectedIface) { const sel = document.getElementById(elemId); if (!sel) return; sel.innerHTML = ''; const ifaces = state.interfaces || []; ifaces.forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.type || 'net'}${i.ip ? ' - ' + i.ip : ''})`; if (selectedIface && selectedIface === i.name) opt.selected = true; sel.appendChild(opt); }); } function openAddVRRPModal() { document.getElementById('modal-vrrp-title').innerText = 'Add VRRP Instance'; document.getElementById('modal-vrrp-id').value = ''; document.getElementById('modal-vrrp-name').value = `vrrp${(state.vrrpInstances || []).length + 1}`; document.getElementById('modal-vrrp-vrid').value = 10 + (state.vrrpInstances || []).length; document.getElementById('modal-vrrp-priority').value = 100; document.getElementById('modal-vrrp-vip').value = '192.168.88.254/24'; document.getElementById('modal-vrrp-version').value = '3'; document.getElementById('modal-vrrp-interval').value = 1; document.getElementById('modal-vrrp-authtype').value = 'none'; document.getElementById('modal-vrrp-authpass').value = ''; document.getElementById('modal-vrrp-comment').value = ''; document.getElementById('modal-vrrp-preempt').checked = true; document.getElementById('modal-vrrp-disabled').checked = false; populateVRRPIfaceSelect('modal-vrrp-iface', ''); document.getElementById('modal-vrrp-instance').classList.remove('hidden'); } function openEditVRRPModal(id) { const inst = (state.vrrpInstances || []).find(item => item.id === id); if (!inst) return; document.getElementById('modal-vrrp-title').innerText = `Edit VRRP Instance (${inst.name})`; document.getElementById('modal-vrrp-id').value = inst.id; document.getElementById('modal-vrrp-name').value = inst.name; document.getElementById('modal-vrrp-vrid').value = inst.vrid; document.getElementById('modal-vrrp-priority').value = inst.priority; document.getElementById('modal-vrrp-vip').value = (inst.virtual_ips || []).join(', '); document.getElementById('modal-vrrp-version').value = String(inst.version || 3); document.getElementById('modal-vrrp-interval').value = inst.interval || 1; document.getElementById('modal-vrrp-authtype').value = inst.auth_type || 'none'; document.getElementById('modal-vrrp-authpass').value = inst.auth_password || ''; document.getElementById('modal-vrrp-comment').value = inst.comment || ''; document.getElementById('modal-vrrp-preempt').checked = inst.preempt !== false; document.getElementById('modal-vrrp-disabled').checked = !!inst.disabled; populateVRRPIfaceSelect('modal-vrrp-iface', inst.interface); document.getElementById('modal-vrrp-instance').classList.remove('hidden'); } function closeVRRPModal() { document.getElementById('modal-vrrp-instance').classList.add('hidden'); } async function submitSaveVRRP(e) { e.preventDefault(); const id = document.getElementById('modal-vrrp-id').value; const name = document.getElementById('modal-vrrp-name').value.trim(); const iface = document.getElementById('modal-vrrp-iface').value; const vrid = parseInt(document.getElementById('modal-vrrp-vrid').value, 10) || 1; const priority = parseInt(document.getElementById('modal-vrrp-priority').value, 10) || 100; const vipRaw = document.getElementById('modal-vrrp-vip').value.trim(); const version = parseInt(document.getElementById('modal-vrrp-version').value, 10) || 3; const interval = parseInt(document.getElementById('modal-vrrp-interval').value, 10) || 1; const authType = document.getElementById('modal-vrrp-authtype').value; const authPass = document.getElementById('modal-vrrp-authpass').value.trim(); const comment = document.getElementById('modal-vrrp-comment').value.trim(); const preempt = document.getElementById('modal-vrrp-preempt').checked; const disabled = document.getElementById('modal-vrrp-disabled').checked; const vips = vipRaw.split(',').map(s => s.trim()).filter(Boolean); try { const res = await fetch(`${API_BASE}/vrrp/instances`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, interface: iface, vrid, priority, virtual_ips: vips, version, interval, auth_type: authType, auth_password: authPass, comment, preempt, disabled }) }); if (res.ok) { showToast('VRRP Saved', `VRRP instance ${name} configured`, 'success'); closeVRRPModal(); loadVRRP(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteVRRP(id, name) { if (!confirm(`Delete VRRP instance "${name}"?`)) return; try { const res = await fetch(`${API_BASE}/vrrp/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `VRRP instance ${name} removed`, 'info'); loadVRRP(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleVRRPState(id, disabled) { try { const res = await fetch(`${API_BASE}/vrrp/instances/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast('State Updated', disabled ? 'VRRP disabled' : 'VRRP enabled', 'info'); loadVRRP(false); fetchData(false); } else { const err = await res.text(); showToast('Error', err, 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // Global observer for dynamically injected tables (e.g. inside modals or drawers) if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') { let docSortDebounce = null; const docTableObserver = new MutationObserver((mutations) => { let needsInit = false; for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === 1) { if (node.tagName === 'TABLE' && !node.closest('tbody, thead')) { needsInit = true; break; } else if (node.querySelector && !node.closest('table') && node.querySelector('table')) { needsInit = true; break; } } } if (needsInit) break; } if (needsInit) { clearTimeout(docSortDebounce); docSortDebounce = setTimeout(initTableSorting, 100); } }); docTableObserver.observe(document.body || document.documentElement, { childList: true, subtree: true }); } // ========================================================================= // BANDWIDTH TEST (BwTest) BENCHMARK ENGINE // ========================================================================= const bwtestState = { running: false, pollTimer: null, eventSource: null, chartPoints: [], maxChartPoints: 60, serverConfig: null, peakTx: 0, peakRx: 0, targetDuration: 10, lastSampleElapsed: -1, sessionID: null }; async function loadBwTestView() { resizeBwTestCanvas(); await Promise.all([fetchBwTestServerStatus(), loadBwTestHistory(), checkActiveBwTest()]); } async function fetchBwTestServerStatus() { try { const res = await fetch(`${API_BASE}/tools/bwtest/server`); if (!res.ok) return; const s = await res.json(); bwtestState.serverConfig = s; // Update indicator and badges const indicator = document.getElementById('bwtest-server-indicator'); const badge = document.getElementById('bwtest-server-badge'); const sideBadge = document.getElementById('badge-bwtest-server'); if (s.enabled && s.running) { if (indicator) indicator.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse'; if (badge) { badge.innerText = `Port ${s.port} (Active)`; badge.className = 'text-emerald-400 font-bold'; } if (sideBadge) { sideBadge.innerText = s.port; sideBadge.className = 'text-[10px] bg-emerald-950 text-emerald-300 border border-emerald-800/40 px-1.5 py-0.2 rounded-full font-mono'; } } else { if (indicator) indicator.className = 'w-2 h-2 rounded-full bg-slate-600'; if (badge) { badge.innerText = `Disabled`; badge.className = 'text-slate-500 font-bold'; } if (sideBadge) { sideBadge.innerText = 'Off'; sideBadge.className = 'text-[10px] bg-slate-800 text-slate-500 border border-slate-700 px-1.5 py-0.2 rounded-full font-mono'; } } // Modal fields const toggle = document.getElementById('bwtest-server-enable-toggle'); const serverModal = document.getElementById('modal-bwtest-server'); const editing = serverModal && !serverModal.classList.contains('hidden'); if (toggle && !editing) toggle.checked = !!s.enabled; const portInp = document.getElementById('bwtest-server-port-input'); if (portInp && !editing) portInp.value = s.port || 2000; const actSess = document.getElementById('bwtest-modal-active-sessions'); if (actSess) actSess.innerText = s.active_sessions || 0; const totServ = document.getElementById('bwtest-modal-total-served'); if (totServ) totServ.innerText = s.total_sessions_served || 0; const totBytes = document.getElementById('bwtest-modal-total-bytes'); if (totBytes) totBytes.innerText = formatBytes(s.total_bytes_processed || 0); } catch (err) { console.error('Failed to fetch BwTest server status:', err); } } function openBwTestServerModal() { const cfg = bwtestState.serverConfig; if (cfg) { document.getElementById('bwtest-server-enable-toggle').checked = !!cfg.enabled; document.getElementById('bwtest-server-port-input').value = cfg.port || 2000; } fetchBwTestServerStatus(); document.getElementById('modal-bwtest-server')?.classList.remove('hidden'); } function closeBwTestServerModal() { document.getElementById('modal-bwtest-server')?.classList.add('hidden'); } async function submitBwTestServerSettings(e) { e.preventDefault(); const enabled = document.getElementById('bwtest-server-enable-toggle').checked; const port = parseInt(document.getElementById('bwtest-server-port-input').value, 10) || 2000; try { const res = await fetch(`${API_BASE}/tools/bwtest/server`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, port, listen_addr: '0.0.0.0', allow_anonymous: true }) }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to update server configuration'); } showToast('BwTest Server', `Bandwidth Test server updated (Port ${port})`, 'success'); closeBwTestServerModal(); fetchBwTestServerStatus(); } catch (err) { showToast('Server Configuration Error', err.message, 'error'); } } function setBwTestTarget(target) { const inp = document.getElementById('bwtest-input-target'); if (inp) inp.value = target; } function setBwTestProtocol(proto) { const hiddenInp = document.getElementById('bwtest-input-protocol'); if (hiddenInp) hiddenInp.value = proto; const btnUdp = document.getElementById('bwtest-proto-udp'); const btnTcp = document.getElementById('bwtest-proto-tcp'); const badge = document.getElementById('bwtest-proto-badge'); const pktField = document.getElementById('bwtest-field-pktsize'); const activeUdpCls = 'py-2.5 px-3 rounded-lg text-xs font-bold border transition text-center bg-emerald-600/20 border-emerald-500/50 text-emerald-300 shadow-sm flex items-center justify-center gap-2'; const activeTcpCls = 'py-2.5 px-3 rounded-lg text-xs font-bold border transition text-center bg-blue-600/20 border-blue-500/50 text-blue-300 shadow-sm flex items-center justify-center gap-2'; const inactiveCls = 'py-2.5 px-3 rounded-lg text-xs font-medium border border-slate-800 bg-slate-950 text-slate-400 hover:text-white transition text-center flex items-center justify-center gap-2'; if (proto === 'udp') { if (btnUdp) { btnUdp.className = activeUdpCls; const dot = btnUdp.querySelector('span'); if (dot) dot.className = 'w-2 h-2 rounded-full bg-emerald-400'; } if (btnTcp) { btnTcp.className = inactiveCls; const dot = btnTcp.querySelector('span'); if (dot) dot.className = 'w-2 h-2 rounded-full bg-slate-600'; } if (badge) { badge.innerText = 'UDP Mode (RFC 3550 Jitter + Loss %)'; badge.className = 'text-[10px] font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20'; } if (pktField) pktField.classList.remove('opacity-40'); } else { if (btnUdp) { btnUdp.className = inactiveCls; const dot = btnUdp.querySelector('span'); if (dot) dot.className = 'w-2 h-2 rounded-full bg-slate-600'; } if (btnTcp) { btnTcp.className = activeTcpCls; const dot = btnTcp.querySelector('span'); if (dot) dot.className = 'w-2 h-2 rounded-full bg-blue-400'; } if (badge) { badge.innerText = 'TCP Mode (Sliding Window Throughput)'; badge.className = 'text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20'; } if (pktField) pktField.classList.add('opacity-40'); } } function setBwTestDirection(dir) { const hiddenInp = document.getElementById('bwtest-input-direction'); if (hiddenInp) hiddenInp.value = dir; const btnBoth = document.getElementById('bwtest-dir-both'); const btnTx = document.getElementById('bwtest-dir-tx'); const btnRx = document.getElementById('bwtest-dir-rx'); const activeCls = 'py-2 px-2 rounded-lg text-xs font-bold border transition text-center bg-emerald-600/20 border-emerald-500/50 text-emerald-300 shadow-sm'; const inactiveCls = 'py-2 px-2 rounded-lg text-xs font-medium border border-slate-800 bg-slate-950 text-slate-400 hover:text-white transition text-center'; if (btnBoth) btnBoth.className = dir === 'both' ? activeCls : inactiveCls; if (btnTx) btnTx.className = dir === 'tx' ? activeCls : inactiveCls; if (btnRx) btnRx.className = dir === 'rx' ? activeCls : inactiveCls; } function setBwTestLimit(bps) { const sel = document.getElementById('bwtest-input-limit'); if (sel) sel.value = String(bps); } async function checkActiveBwTest() { try { const res = await fetch(`${API_BASE}/tools/bwtest/status`); if (!res.ok) return; const st = await res.json(); if (st.running) { setBwTestRunningUI(true); processBwTestStatus(st); startBwTestTelemetry(); } } catch (err) { console.error('Failed to check active BwTest:', err); } } async function startBwTest(e) { if (e && e.preventDefault) e.preventDefault(); const target = document.getElementById('bwtest-input-target').value.trim() || '127.0.0.1'; const port = parseInt(document.getElementById('bwtest-input-port').value, 10) || 2000; const direction = document.getElementById('bwtest-input-direction').value || 'both'; const protocol = document.getElementById('bwtest-input-protocol').value || 'udp'; const bandwidth_limit = parseInt(document.getElementById('bwtest-input-limit').value, 10) || 0; const packet_size = parseInt(document.getElementById('bwtest-input-pktsize').value, 10) || 1472; const streams = parseInt(document.getElementById('bwtest-input-streams').value, 10) || 1; const duration = parseInt(document.getElementById('bwtest-input-duration').value, 10); bwtestState.targetDuration = duration; bwtestState.chartPoints = []; bwtestState.peakTx = 0; bwtestState.peakRx = 0; bwtestState.lastSampleElapsed = -1; bwtestState.sessionID = null; // Clear UI elements document.getElementById('bwtest-stat-tx-mbps').innerText = '0.00'; document.getElementById('bwtest-stat-tx-peak').innerText = '0.00 Mbps'; document.getElementById('bwtest-stat-tx-pkts').innerText = '0'; document.getElementById('bwtest-stat-rx-mbps').innerText = '0.00'; document.getElementById('bwtest-stat-rx-peak').innerText = '0.00 Mbps'; document.getElementById('bwtest-stat-rx-pkts').innerText = '0'; document.getElementById('bwtest-stat-loss').innerText = '0.0%'; document.getElementById('bwtest-stat-jitter').innerText = '0.00 ms'; document.getElementById('bwtest-stat-time').innerText = '0.0s'; document.getElementById('bwtest-stat-data').innerText = '0.0 MB'; document.getElementById('bwtest-stat-target-dur').innerText = duration > 0 ? `/ ${duration}s` : '/ ∞'; const tbody = document.getElementById('bwtest-samples-tbody'); if (tbody) tbody.innerHTML = ''; document.getElementById('bwtest-sample-count').innerText = '0 samples'; setBwTestRunningUI(true); try { const res = await fetch(`${API_BASE}/tools/bwtest/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target, port, direction, protocol, bandwidth_limit, packet_size, streams, duration }) }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to start bandwidth test'); } showToast('Benchmark Started', `Target: ${target}:${port} (${direction.toUpperCase()}, ${protocol.toUpperCase()})`, 'info'); startBwTestTelemetry(); } catch (err) { setBwTestRunningUI(false); showToast('Start Failed', err.message, 'error'); } } function setBwTestRunningUI(running) { bwtestState.running = running; const startBtn = document.getElementById('bwtest-btn-start'); const stopBtn = document.getElementById('bwtest-btn-stop'); const badge = document.getElementById('bwtest-run-status-badge'); const ph = document.getElementById('bwtest-chart-placeholder'); if (running) { if (startBtn) { startBtn.disabled = true; startBtn.classList.add('opacity-50', 'cursor-not-allowed'); } if (stopBtn) { stopBtn.disabled = false; stopBtn.classList.remove('opacity-40'); } if (badge) { badge.innerText = 'TESTING...'; badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold animate-pulse'; } if (ph) ph.style.display = 'none'; } else { if (startBtn) { startBtn.disabled = false; startBtn.classList.remove('opacity-50', 'cursor-not-allowed'); } if (stopBtn) { stopBtn.disabled = true; stopBtn.classList.add('opacity-40'); } if (badge) { badge.innerText = 'IDLE'; badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 text-slate-400 border border-slate-700 font-semibold'; } } } async function stopBwTest() { try { const res = await fetch(`${API_BASE}/tools/bwtest/stop`, { method: 'POST' }); if (!res.ok) throw new Error(await responseError(res)); showToast('Stop Requested', 'Waiting for the bandwidth test to finish', 'info'); if (!bwtestState.pollTimer) startBwTestTelemetry(); } catch (err) { showToast('Stop Failed', err.message, 'error'); } } async function responseError(res) { const text = await res.text(); try { const data = JSON.parse(text); return data.message || (typeof data.error === 'string' ? data.error : null) || `HTTP ${res.status}`; } catch { return text || `HTTP ${res.status}`; } } function processBwTestStatus(st) { if (st.session_id && bwtestState.sessionID !== st.session_id) { bwtestState.sessionID = st.session_id; bwtestState.lastSampleElapsed = -1; } for (const sample of st.recent_samples || []) { if (sample.elapsed_sec > (bwtestState.lastSampleElapsed ?? -1)) { handleBwTestSample(sample); bwtestState.lastSampleElapsed = sample.elapsed_sec; } } if (!st.running) { if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer); bwtestState.pollTimer = null; setBwTestRunningUI(false); handleBwTestFinish(st.summary, st.error); } } function startBwTestTelemetry() { if (!state.currentUser) return; if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer); let busy = false; const poll = async () => { if (busy || !state.currentUser || document.hidden) return; busy = true; try { const res = await fetch(`${API_BASE}/tools/bwtest/status`); if (!res.ok) throw new Error(await responseError(res)); processBwTestStatus(await res.json()); } catch (err) { console.error('BwTest polling error:', err); } finally { busy = false; } }; bwtestState.pollTimer = setInterval(poll, 400); poll(); } function handleBwTestSample(s) { if (!s) return; const payloadHint = document.getElementById('bwtest-effective-payload'); if (payloadHint && s.packet_size) payloadHint.innerText = `Effective datagram: ${s.packet_size} bytes`; const txMbps = s.tx_mbps || 0; const rxMbps = s.rx_mbps || 0; if (txMbps > bwtestState.peakTx) bwtestState.peakTx = txMbps; if (rxMbps > bwtestState.peakRx) bwtestState.peakRx = rxMbps; // KPI updates const elTx = document.getElementById('bwtest-stat-tx-mbps'); if (elTx) { elTx.innerText = txMbps.toFixed(2); elTx.title = `Confirmed by peer; locally offered: ${(s.offered_tx_mbps || 0).toFixed(2)} Mbps`; } const elTxPeak = document.getElementById('bwtest-stat-tx-peak'); if (elTxPeak) elTxPeak.innerText = `${bwtestState.peakTx.toFixed(2)} Mbps`; const elTxPkts = document.getElementById('bwtest-stat-tx-pkts'); if (elTxPkts) elTxPkts.innerText = s.tx_packets || 0; const elRx = document.getElementById('bwtest-stat-rx-mbps'); if (elRx) elRx.innerText = rxMbps.toFixed(2); const elRxPeak = document.getElementById('bwtest-stat-rx-peak'); if (elRxPeak) elRxPeak.innerText = `${bwtestState.peakRx.toFixed(2)} Mbps`; const elRxPkts = document.getElementById('bwtest-stat-rx-pkts'); if (elRxPkts) elRxPkts.innerText = s.rx_packets || 0; const elLoss = document.getElementById('bwtest-stat-loss'); if (elLoss) { elLoss.innerText = `${(s.loss_pct || 0).toFixed(1)}%`; elLoss.className = (s.loss_pct > 1.0) ? 'text-2xl font-bold text-rose-400' : 'text-2xl font-bold text-emerald-400'; } const elLostPkts = document.getElementById('bwtest-stat-lost-pkts'); if (elLostPkts) elLostPkts.innerText = s.lost_packets || 0; const elJitter = document.getElementById('bwtest-stat-jitter'); if (elJitter) elJitter.innerText = `${(s.jitter_ms || 0).toFixed(2)} ms`; const elTime = document.getElementById('bwtest-stat-time'); if (elTime) elTime.innerText = `${(s.elapsed_sec || 0).toFixed(1)}s`; const elData = document.getElementById('bwtest-stat-data'); if (elData) elData.innerText = formatBytes((s.tx_bytes || 0) + (s.rx_bytes || 0)); const elGraphRate = document.getElementById('bwtest-graph-rate'); if (elGraphRate) elGraphRate.innerText = `TX: ${txMbps.toFixed(1)} Mbps • RX: ${rxMbps.toFixed(1)} Mbps`; // Append point to chart buffer bwtestState.chartPoints.push({ elapsed: s.elapsed_sec || 0, tx: txMbps, rx: rxMbps }); if (bwtestState.chartPoints.length > bwtestState.maxChartPoints) { bwtestState.chartPoints.shift(); } drawBwTestChart(); // Add row to breakdown table const tbody = document.getElementById('bwtest-samples-tbody'); if (tbody) { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs'; tr.innerHTML = ` ${(s.elapsed_sec || 0).toFixed(1)}s ${txMbps.toFixed(2)} Mbps ${rxMbps.toFixed(2)} Mbps ${(s.loss_pct || 0).toFixed(1)}% ${(s.jitter_ms || 0).toFixed(2)} ms ${formatBytes((s.tx_bytes || 0) + (s.rx_bytes || 0))} `; tbody.insertBefore(tr, tbody.firstChild); const countEl = document.getElementById('bwtest-sample-count'); if (countEl) countEl.innerText = `${tbody.children.length} samples`; } } function handleBwTestFinish(summary, error) { const badge = document.getElementById('bwtest-run-status-badge'); if (error) { showToast('Benchmark Error', error, 'error'); if (badge) { badge.innerText = 'ERROR'; badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold'; } } else if (summary) { const avgTx = summary.avg_tx_mbps.toFixed(1); const avgRx = summary.avg_rx_mbps.toFixed(1); showToast('Benchmark Complete', `Average TX: ${avgTx} Mbps | RX: ${avgRx} Mbps (Loss: ${summary.avg_loss_pct.toFixed(1)}%)`, 'success'); if (badge) { badge.innerText = 'COMPLETED'; badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold'; } } loadBwTestHistory(); fetchBwTestServerStatus(); } function resizeBwTestCanvas() { const canvas = document.getElementById('bwtest-chart'); if (!canvas || !canvas.parentElement) return; const rect = canvas.parentElement.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); drawBwTestChart(); } window.addEventListener('resize', () => { if (state.activeTab === 'bwtest') resizeBwTestCanvas(); }); function drawBwTestChart() { const canvas = document.getElementById('bwtest-chart'); if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const width = canvas.width / dpr; const height = canvas.height / dpr; ctx.clearRect(0, 0, width, height); const points = bwtestState.chartPoints; if (!points || points.length === 0) return; // Determine scale ceiling let maxRate = 10; for (const p of points) { if (p.tx > maxRate) maxRate = p.tx; if (p.rx > maxRate) maxRate = p.rx; } maxRate = Math.ceil(maxRate * 1.25); // 25% headroom const paddingLeft = 55; const paddingBottom = 24; const paddingTop = 12; const paddingRight = 16; const chartW = width - paddingLeft - paddingRight; const chartH = height - paddingTop - paddingBottom; // Grid Lines and Y-Axis labels ctx.strokeStyle = 'rgba(51, 65, 85, 0.4)'; ctx.lineWidth = 1; ctx.fillStyle = '#94a3b8'; ctx.font = '10px monospace'; ctx.textAlign = 'right'; const gridSteps = 4; for (let i = 0; i <= gridSteps; i++) { const val = (maxRate / gridSteps) * i; const y = paddingTop + chartH - (i / gridSteps) * chartH; ctx.beginPath(); ctx.moveTo(paddingLeft, y); ctx.lineTo(width - paddingRight, y); ctx.stroke(); ctx.fillText(`${Math.round(val)}M`, paddingLeft - 8, y + 3); } // Draw TX Line (Cyan #22d3ee) drawSeries(ctx, points, p => p.tx, maxRate, paddingLeft, paddingTop, chartW, chartH, '#22d3ee', 'rgba(34, 211, 238, 0.15)'); // Draw RX Line (Purple #a855f7) drawSeries(ctx, points, p => p.rx, maxRate, paddingLeft, paddingTop, chartW, chartH, '#a855f7', 'rgba(168, 85, 247, 0.15)'); } function drawSeries(ctx, points, valFn, maxVal, left, top, w, h, strokeColor, fillColor) { if (points.length < 2) return; const stepX = w / Math.max(points.length - 1, 1); // Gradient area fill const grad = ctx.createLinearGradient(0, top, 0, top + h); grad.addColorStop(0, fillColor); grad.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.beginPath(); points.forEach((p, idx) => { const x = left + idx * stepX; const val = valFn(p); const y = top + h - (val / maxVal) * h; if (idx === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); // Complete path for fill ctx.lineTo(left + (points.length - 1) * stepX, top + h); ctx.lineTo(left, top + h); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); // Draw line stroke ctx.beginPath(); ctx.lineWidth = 2.5; ctx.strokeStyle = strokeColor; points.forEach((p, idx) => { const x = left + idx * stepX; const val = valFn(p); const y = top + h - (val / maxVal) * h; if (idx === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); // Draw end point halo const lastP = points[points.length - 1]; const lastX = left + (points.length - 1) * stepX; const lastY = top + h - (valFn(lastP) / maxVal) * h; ctx.fillStyle = strokeColor; ctx.beginPath(); ctx.arc(lastX, lastY, 4, 0, 2 * Math.PI); ctx.fill(); } async function loadBwTestHistory() { try { const res = await fetch(`${API_BASE}/tools/bwtest/history`); if (!res.ok) return; const list = await res.json(); renderBwTestHistory(Array.isArray(list) ? list : []); } catch (err) { console.error('Failed to load BwTest history:', err); } } function renderBwTestHistory(list) { const tbody = document.getElementById('bwtest-history-tbody'); if (!tbody) return; if (list.length === 0) { tbody.innerHTML = `No past benchmark runs recorded yet.`; return; } tbody.innerHTML = ''; list.forEach(item => { const s = item.summary || {}; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs'; const dirBadge = item.direction === 'both' ? `Both` : item.direction === 'tx' ? `TX Only` : `RX Only`; const protoBadge = item.protocol === 'tcp' ? `TCP` : `UDP`; const dateStr = item.timestamp ? new Date(item.timestamp).toLocaleString() : '-'; tr.innerHTML = ` ${escapeHtml(item.id || '-')} ${escapeHtml(item.target || '-')} ${protoBadge} ${dirBadge} ${(s.duration_sec || 0).toFixed(1)}s ${(s.avg_tx_mbps || 0).toFixed(1)} / ${(s.peak_tx_mbps || 0).toFixed(1)} Mbps ${(s.avg_rx_mbps || 0).toFixed(1)} / ${(s.peak_rx_mbps || 0).toFixed(1)} Mbps ${(s.avg_loss_pct || 0).toFixed(1)}% (${s.total_lost_packets || 0}) ${(s.avg_jitter_ms || 0).toFixed(2)} ms ${escapeHtml(dateStr)} `; tbody.appendChild(tr); }); } // ======================== SETTINGS & ROUTER DNS ======================== state.settingsSubTab = 'dns'; state.dnsSettings = null; state.dnsStaticRecords = []; state.dnsStatus = null; function switchSettingsSubTab(sub) { state.settingsSubTab = sub; const btnDns = document.getElementById('btn-settings-subtab-dns'); const btnSys = document.getElementById('btn-settings-subtab-system'); const tabDns = document.getElementById('settings-subtab-dns'); const tabSys = document.getElementById('settings-subtab-system'); if (sub === 'dns') { if (btnDns) { btnDns.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5'; } if (btnSys) { btnSys.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5'; } tabDns?.classList.remove('hidden'); tabSys?.classList.add('hidden'); loadDNSSettings(); } else { if (btnSys) { btnSys.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5'; } if (btnDns) { btnDns.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5'; } tabSys?.classList.remove('hidden'); tabDns?.classList.add('hidden'); loadSystemSettings(); } } async function loadSettingsData(showLoading = false) { if (state.settingsSubTab === 'system') { await loadSystemSettings(); } else { await loadDNSSettings(); } } async function loadDNSSettings() { try { const [settingsRes, staticRes, statusRes] = await Promise.all([ fetch(`${API_BASE}/dns/settings`), fetch(`${API_BASE}/dns/static`), fetch(`${API_BASE}/dns/status`) ]); if (settingsRes.ok) { state.dnsSettings = await settingsRes.json(); populateDNSSettingsForm(state.dnsSettings); } if (staticRes.ok) { state.dnsStaticRecords = await staticRes.json(); renderStaticDNSList(); } if (statusRes.ok) { state.dnsStatus = await statusRes.json(); updateDNSStatusView(state.dnsStatus); } } catch (err) { console.error('Failed to load DNS settings:', err); } } function populateDNSSettingsForm(s) { if (!s) return; const dnsForm = document.getElementById('form-dns-settings'); const isEditing = dnsForm && (dnsForm.contains(document.activeElement) || dnsForm.dataset.dirty === 'true'); const dynamicEl = document.getElementById('dns-dynamic-servers-list'); if (dynamicEl) { if (Array.isArray(s.dynamic_servers) && s.dynamic_servers.length > 0) { dynamicEl.innerHTML = s.dynamic_servers.map(d => `${escapeHtml(d)}`).join(' '); } else { dynamicEl.innerText = 'None'; } } // If user is actively typing or editing in the form, do not overwrite their inputs! if (isEditing) return; const serversInput = document.getElementById('dns-servers-input'); if (serversInput) serversInput.value = (s.servers || []).join(', '); const cacheSizeInput = document.getElementById('dns-cache-size-input'); if (cacheSizeInput) cacheSizeInput.value = s.cache_size || 2048; const domainInput = document.getElementById('dns-domain-input'); if (domainInput) domainInput.value = s.domain || ''; const allowRemoteInput = document.getElementById('dns-allow-remote-input'); if (allowRemoteInput) allowRemoteInput.checked = s.allow_remote_requests !== false; } function updateDNSStatusView(st) { if (!st) return; const preview = document.getElementById('dns-resolv-preview'); if (preview) preview.innerText = st.resolv_conf_content || '(empty)'; const pillText = document.getElementById('dns-live-status-text'); const pill = document.getElementById('dns-live-status-pill'); if (pill && pillText) { if (st.service_running) { pill.className = 'px-2.5 py-1 rounded-full text-[11px] font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5'; pillText.innerText = 'DNS Service Active'; } else { pill.className = 'px-2.5 py-1 rounded-full text-[11px] font-mono bg-rose-500/10 text-rose-400 border border-rose-500/30 flex items-center gap-1.5'; pillText.innerText = 'DNS Service Inactive'; } } } function appendDNSPreset(preset) { const input = document.getElementById('dns-servers-input'); if (!input) return; const dnsForm = document.getElementById('form-dns-settings'); if (dnsForm) dnsForm.dataset.dirty = 'true'; const current = input.value.trim(); if (!current) { input.value = preset; } else { const existing = current.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); const toAdd = preset.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); toAdd.forEach(ip => { if (!existing.includes(ip)) existing.push(ip); }); input.value = existing.join(', '); } } async function saveDNSSettings(e) { e.preventDefault(); const rawServers = document.getElementById('dns-servers-input')?.value || ''; const servers = rawServers.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); if (servers.length === 0) { showToast('Validation Error', 'Please enter at least one upstream DNS server IP', 'error'); return; } const cacheSize = parseInt(document.getElementById('dns-cache-size-input')?.value || '2048', 10); const domain = (document.getElementById('dns-domain-input')?.value || '').trim(); const allowRemote = document.getElementById('dns-allow-remote-input')?.checked ?? true; try { const res = await fetch(`${API_BASE}/dns/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ servers: servers, cache_size: cacheSize, domain: domain, allow_remote_requests: allowRemote }) }); const data = await res.json(); if (res.ok) { const dnsForm = document.getElementById('form-dns-settings'); if (dnsForm) dnsForm.dataset.dirty = 'false'; showToast('DNS Applied', 'Router DNS forwarders and resolver settings updated successfully', 'success'); loadDNSSettings(); } else { showToast('Error', data.error || data.message || 'Failed to save DNS settings', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function flushDNSCache() { try { const res = await fetch(`${API_BASE}/dns/flush`, { method: 'POST' }); if (res.ok) { showToast('Cache Flushed', 'Router DNS cache cleared and static hosts reloaded', 'success'); loadDNSSettings(); } else { const err = await res.json(); showToast('Error', err.error || 'Failed to flush cache', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function renderStaticDNSList() { const tbody = document.getElementById('dns-static-tbody'); const empty = document.getElementById('dns-static-empty'); const badge = document.getElementById('dns-static-count-badge'); if (!tbody) return; tbody.innerHTML = ''; const records = state.dnsStaticRecords || []; if (badge) badge.innerText = `${records.length} Records`; if (records.length === 0) { empty?.classList.remove('hidden'); return; } empty?.classList.add('hidden'); records.forEach(rec => { const tr = document.createElement('tr'); tr.className = `hover:bg-slate-800/40 transition text-xs ${rec.disabled ? 'opacity-50' : ''}`; tr.innerHTML = ` ${escapeHtml(rec.name)} ${rec.type || 'A'} ${escapeHtml(rec.address)} ${rec.ttl || 3600}s ${escapeHtml(rec.comment || '-')} ${rec.disabled ? 'Disabled' : 'Active'} `; tbody.appendChild(tr); }); } function openStaticDNSModal(id = null) { const modal = document.getElementById('modal-static-dns'); const title = document.getElementById('modal-static-dns-title'); const form = document.getElementById('form-static-dns'); if (!modal || !form) return; form.reset(); document.getElementById('modal-static-dns-id').value = ''; if (id) { const rec = (state.dnsStaticRecords || []).find(r => r.id === id); if (rec) { if (title) title.innerText = 'Edit Static DNS Record'; document.getElementById('modal-static-dns-id').value = rec.id; document.getElementById('modal-static-dns-name').value = rec.name; document.getElementById('modal-static-dns-type').value = rec.type || 'A'; document.getElementById('modal-static-dns-addr').value = rec.address; document.getElementById('modal-static-dns-ttl').value = rec.ttl || 3600; document.getElementById('modal-static-dns-comment').value = rec.comment || ''; document.getElementById('modal-static-dns-disabled').checked = !!rec.disabled; } } else { if (title) title.innerText = 'Add Static DNS Record'; } modal.classList.remove('hidden'); } function closeStaticDNSModal() { document.getElementById('modal-static-dns')?.classList.add('hidden'); } async function submitSaveStaticDNS(e) { e.preventDefault(); const id = document.getElementById('modal-static-dns-id')?.value || ''; const name = (document.getElementById('modal-static-dns-name')?.value || '').trim(); const type = document.getElementById('modal-static-dns-type')?.value || 'A'; const address = (document.getElementById('modal-static-dns-addr')?.value || '').trim(); const ttl = parseInt(document.getElementById('modal-static-dns-ttl')?.value || '3600', 10); const comment = (document.getElementById('modal-static-dns-comment')?.value || '').trim(); const disabled = document.getElementById('modal-static-dns-disabled')?.checked ?? false; if (!name || !address) { showToast('Validation Error', 'Name and Target Address are required', 'error'); return; } try { const res = await fetch(`${API_BASE}/dns/static`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name, type, address, ttl, comment, disabled }) }); const data = await res.json(); if (res.ok) { showToast('Static DNS Saved', `Saved record ${name} -> ${address}`, 'success'); closeStaticDNSModal(); loadDNSSettings(); } else { showToast('Error', data.error || data.message || 'Failed to save record', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteStaticDNS(id) { if (!confirm('Are you sure you want to delete this static DNS entry?')) return; try { const res = await fetch(`${API_BASE}/dns/static/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', 'Static DNS record deleted', 'success'); loadDNSSettings(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleStaticDNS(id, disabled) { try { const res = await fetch(`${API_BASE}/dns/static/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }) }); if (res.ok) { showToast('Updated', `Record ${disabled ? 'disabled' : 'enabled'}`, 'success'); loadDNSSettings(); } } catch (err) { showToast('Error', err.message, 'error'); } } async function loadSystemSettings() { try { const sysForm = document.getElementById('form-system-settings'); const isEditing = sysForm && (sysForm.contains(document.activeElement) || sysForm.dataset.dirty === 'true'); const res = await fetch(`${API_BASE}/system/settings`); if (res.ok) { const data = await res.json(); if (!isEditing) { const hostInput = document.getElementById('system-hostname-input'); if (hostInput) hostInput.value = data.hostname || ''; const tzInput = document.getElementById('system-timezone-input'); if (tzInput && data.timezone) tzInput.value = data.timezone; } } } catch (err) { console.error('Failed to load system settings:', err); } } async function saveSystemSettings(e) { e.preventDefault(); const hostname = (document.getElementById('system-hostname-input')?.value || '').trim(); const timezone = document.getElementById('system-timezone-input')?.value || 'UTC'; try { const res = await fetch(`${API_BASE}/system/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostname, timezone }) }); const data = await res.json(); if (res.ok) { const sysForm = document.getElementById('form-system-settings'); if (sysForm) sysForm.dataset.dirty = 'false'; showToast('System Updated', 'Hostname and timezone saved successfully', 'success'); const sideH = document.getElementById('side-hostname'); if (sideH) sideH.innerText = data.hostname; } else { showToast('Error', data.error || 'Failed to save system settings', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ============================================================================= // AUTHENTICATION & USER MANAGER // ============================================================================= async function checkAuthStatus() { try { const res = await originalFetch(`${API_BASE}/auth/me`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('debtik_token') || ''}` } }); if (res.ok) { const user = await res.json(); state.currentUser = user; updateUserProfileHeader(user); hideLoginScreen(); state.isPolling = true; fetchData(); initLiveTrafficStream(); return true; } else { showLoginScreen(false); return false; } } catch (err) { showLoginScreen(false); return false; } } function showLoginScreen(isExpired = false) { const el = document.getElementById('login-screen'); if (el) el.classList.remove('hidden'); const errEl = document.getElementById('login-error'); const errTxt = document.getElementById('login-error-text'); if (isExpired && errEl && errTxt) { errTxt.innerText = 'Session expired. Please sign in again.'; errEl.classList.remove('hidden'); } else if (errEl) { errEl.classList.add('hidden'); } const pwInput = document.getElementById('login-password'); if (pwInput) pwInput.value = ''; const userInput = document.getElementById('login-username'); if (userInput && !userInput.value) userInput.focus(); else if (pwInput) pwInput.focus(); } function hideLoginScreen() { const el = document.getElementById('login-screen'); if (el) el.classList.add('hidden'); const errEl = document.getElementById('login-error'); if (errEl) errEl.classList.add('hidden'); } async function handleLoginSubmit(event) { event.preventDefault(); const username = (document.getElementById('login-username')?.value || '').trim(); const password = document.getElementById('login-password')?.value || ''; const errEl = document.getElementById('login-error'); const errTxt = document.getElementById('login-error-text'); const btn = document.getElementById('login-submit-btn'); const btnText = document.getElementById('login-btn-text'); const spinner = document.getElementById('login-btn-spinner'); if (!username || !password) { if (errTxt) errTxt.innerText = 'Username and password are required.'; if (errEl) errEl.classList.remove('hidden'); return; } if (errEl) errEl.classList.add('hidden'); if (btn) btn.disabled = true; if (btnText) btnText.innerText = 'Authenticating...'; if (spinner) spinner.classList.remove('hidden'); try { const res = await originalFetch(`${API_BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await res.json(); if (res.ok && data.success) { localStorage.setItem('debtik_token', data.token); state.currentUser = data.user; updateUserProfileHeader(data.user); hideLoginScreen(); state.isPolling = true; showToast('Authenticated', `Signed in as ${data.user.username}`, 'success'); const hash = window.location.hash.replace('#', '') || 'dashboard'; navigate(hash); fetchData(); initLiveTrafficStream(); // Ensure background telemetry polling is active if (!window._telemetryInterval) { window._telemetryInterval = setInterval(() => { if (state.isPolling && state.currentUser && !document.hidden) { fetchData(false); } }, 5000); } } else { if (errTxt) errTxt.innerText = data.message || 'Invalid username or password'; if (errEl) errEl.classList.remove('hidden'); } } catch (err) { if (errTxt) errTxt.innerText = err.message || 'Failed to connect to router'; if (errEl) errEl.classList.remove('hidden'); } finally { if (btn) btn.disabled = false; if (btnText) btnText.innerText = 'Sign In to Controller'; if (spinner) spinner.classList.add('hidden'); } } async function handleLogoutClick() { try { await originalFetch(`${API_BASE}/auth/logout`, { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('debtik_token') || ''}` } }); } catch (e) {} stopSessionPolling(); localStorage.removeItem('debtik_token'); state.currentUser = null; state.isPolling = false; if (window._telemetryInterval) { clearInterval(window._telemetryInterval); window._telemetryInterval = null; } if (trafficEventSource) { trafficEventSource.close(); trafficEventSource = null; } if (trafficPollFallback) { clearInterval(trafficPollFallback); trafficPollFallback = null; } showLoginScreen(false); showToast('Signed Out', 'You have been signed out successfully', 'info'); } function togglePasswordVisibility(id) { const input = document.getElementById(id); if (!input) return; input.type = input.type === 'password' ? 'text' : 'password'; } function toggleUserMenu() { const menu = document.getElementById('user-menu-dropdown'); if (menu) menu.classList.toggle('hidden'); } function closeUserMenu() { const menu = document.getElementById('user-menu-dropdown'); if (menu) menu.classList.add('hidden'); } // Close user menu on outside click document.addEventListener('click', (e) => { const menu = document.getElementById('user-menu-dropdown'); if (!menu || menu.classList.contains('hidden')) return; if (!e.target.closest('#user-menu-dropdown') && !e.target.closest('button[onclick*="toggleUserMenu"]')) { menu.classList.add('hidden'); } }); function updateUserProfileHeader(user) { if (!user) return; const navName = document.getElementById('nav-user-name'); if (navName) navName.innerText = user.username; const dropName = document.getElementById('dropdown-user-name'); if (dropName) dropName.innerText = user.username; const navRole = document.getElementById('nav-user-role'); if (navRole) navRole.innerText = (user.role || 'read').toUpperCase(); const navAvatar = document.getElementById('nav-user-avatar'); if (navAvatar) { const initials = (user.username || 'AD').slice(0, 2).toUpperCase(); navAvatar.innerText = initials; } const statCurUser = document.getElementById('stat-current-user'); if (statCurUser) statCurUser.innerText = user.username; const statCurRole = document.getElementById('stat-current-role'); if (statCurRole) statCurRole.innerText = (user.role || 'read').toUpperCase(); // Role permissions UI visibility: hide user management for non-admins const isFullAdmin = user.role === 'full'; const navUsers = document.getElementById('nav-users'); if (navUsers) { if (isFullAdmin) navUsers.classList.remove('hidden'); else navUsers.classList.add('hidden'); } const dropdownUsers = document.getElementById('dropdown-item-users'); if (dropdownUsers) { if (isFullAdmin) dropdownUsers.classList.remove('hidden'); else dropdownUsers.classList.add('hidden'); } } // ================= User Management CRUD ================= async function loadUsers(isManual = false) { try { const res = await fetch(`${API_BASE}/users`); if (res.status === 403) { const tbody = document.getElementById('users-table-tbody'); if (tbody) { tbody.innerHTML = `
Viewing and managing users requires Super Administrator (full) permissions.
`; } return; } if (res.ok) { state.users = await res.json(); renderUsersTable(); const totalUsers = document.getElementById('stat-total-users'); if (totalUsers) totalUsers.innerText = state.users.length; const adminCount = state.users.filter(u => u.role === 'full' && !u.disabled).length; const adminUsers = document.getElementById('stat-admin-users'); if (adminUsers) adminUsers.innerText = adminCount; const badgeCount = document.getElementById('badge-users-count'); if (badgeCount) badgeCount.innerText = state.users.length; if (isManual) showToast('Refreshed', 'User list updated', 'info'); } } catch (err) { console.error('Failed to load users:', err); } } function renderUsersTable() { const tbody = document.getElementById('users-table-tbody'); const emptyState = document.getElementById('users-empty-state'); if (!tbody) return; tbody.innerHTML = ''; const query = (document.getElementById('users-search-input')?.value || '').trim().toLowerCase(); const filtered = (state.users || []).filter(u => { if (!query) return true; return u.username.toLowerCase().includes(query) || (u.role || '').toLowerCase().includes(query) || (u.comment || '').toLowerCase().includes(query); }); if (filtered.length === 0) { if (emptyState) emptyState.classList.remove('hidden'); return; } if (emptyState) emptyState.classList.add('hidden'); const currentAuthUser = state.currentUser?.username || ''; filtered.forEach(u => { const isSelf = u.username === currentAuthUser; const isFull = u.role === 'full'; const isWrite = u.role === 'write'; let roleBadge = `READ (OBSERVER)`; if (isFull) { roleBadge = `FULL (SUPER ADMIN)`; } else if (isWrite) { roleBadge = `WRITE (OPERATOR)`; } const statusBadge = u.disabled ? `Disabled` : `Active`; let lastLoginText = 'Never'; if (u.last_login_at && !u.last_login_at.startsWith('0001-01-01')) { const dt = new Date(u.last_login_at); lastLoginText = dt.toLocaleString(); } const initials = u.username.slice(0, 2).toUpperCase(); const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition group'; tr.innerHTML = `
${initials}
${escapeHtml(u.username)} ${isSelf ? 'YOU' : ''}
Created: ${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}
${roleBadge} ${statusBadge} ${escapeHtml(u.comment) || '-'} ${lastLoginText} ${!isSelf ? ` ` : ''} `; tbody.appendChild(tr); }); } function openAddUserModal() { document.getElementById('user-add-username').value = ''; document.getElementById('user-add-password').value = ''; document.getElementById('user-add-confirm').value = ''; document.getElementById('user-add-role').value = 'full'; document.getElementById('user-add-comment').value = ''; document.getElementById('modal-add-user')?.classList.remove('hidden'); document.getElementById('user-add-username')?.focus(); } function closeAddUserModal() { document.getElementById('modal-add-user')?.classList.add('hidden'); } async function submitAddUser(event) { event.preventDefault(); const username = (document.getElementById('user-add-username')?.value || '').trim(); const password = document.getElementById('user-add-password')?.value || ''; const confirm = document.getElementById('user-add-confirm')?.value || ''; const role = document.getElementById('user-add-role')?.value || 'full'; const comment = (document.getElementById('user-add-comment')?.value || '').trim(); if (password !== confirm) { showToast('Validation Error', 'Passwords do not match', 'warning'); return; } if (password.length < 4) { showToast('Validation Error', 'Password must be at least 4 characters', 'warning'); return; } try { const res = await fetch(`${API_BASE}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, role, comment }) }); const data = await res.json(); if (res.ok) { showToast('User Created', `Created user ${username} with ${role.toUpperCase()} permissions`, 'success'); closeAddUserModal(); loadUsers(false); } else { showToast('Error', data.message || 'Failed to create user', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function openEditUserModal(username) { const user = (state.users || []).find(u => u.username === username); if (!user) return; document.getElementById('user-edit-username').value = user.username; document.getElementById('user-edit-username-title').innerText = `@${user.username}`; document.getElementById('user-edit-role').value = user.role || 'read'; document.getElementById('user-edit-comment').value = user.comment || ''; document.getElementById('user-edit-disabled').checked = !!user.disabled; document.getElementById('modal-edit-user')?.classList.remove('hidden'); } function closeEditUserModal() { document.getElementById('modal-edit-user')?.classList.add('hidden'); } async function submitEditUser(event) { event.preventDefault(); const username = document.getElementById('user-edit-username')?.value; const role = document.getElementById('user-edit-role')?.value || 'read'; const comment = (document.getElementById('user-edit-comment')?.value || '').trim(); const disabled = document.getElementById('user-edit-disabled')?.checked || false; try { const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, comment, disabled }) }); const data = await res.json(); if (res.ok) { showToast('User Updated', `Updated settings for ${username}`, 'success'); closeEditUserModal(); loadUsers(false); // If updated own role/disabled status, refresh self profile if (username === state.currentUser?.username) { checkAuthStatus(); } } else { showToast('Error', data.message || 'Failed to update user', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function openAdminResetPasswordModal(username) { document.getElementById('admin-reset-username').value = username; document.getElementById('admin-reset-username-title').innerText = `@${username}`; document.getElementById('admin-reset-new-pw').value = ''; document.getElementById('admin-reset-confirm-pw').value = ''; document.getElementById('modal-admin-reset-pw')?.classList.remove('hidden'); document.getElementById('admin-reset-new-pw')?.focus(); } function closeAdminResetPasswordModal() { document.getElementById('modal-admin-reset-pw')?.classList.add('hidden'); } async function submitAdminResetPassword(event) { event.preventDefault(); const username = document.getElementById('admin-reset-username')?.value; const newPassword = document.getElementById('admin-reset-new-pw')?.value || ''; const confirm = document.getElementById('admin-reset-confirm-pw')?.value || ''; if (newPassword !== confirm) { showToast('Validation Error', 'Passwords do not match', 'warning'); return; } if (newPassword.length < 4) { showToast('Validation Error', 'Password must be at least 4 characters', 'warning'); return; } try { const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_password: newPassword }) }); const data = await res.json(); if (res.ok) { showToast('Password Reset', `Password for user ${username} updated successfully`, 'success'); closeAdminResetPasswordModal(); } else { showToast('Error', data.message || 'Failed to reset password', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteUser(username) { if (username === state.currentUser?.username) { showToast('Action Forbidden', 'You cannot delete your own account', 'warning'); return; } if (!confirm(`Are you sure you want to completely remove user "${username}"?`)) { return; } try { const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`, { method: 'DELETE' }); const data = await res.json(); if (res.ok) { showToast('User Deleted', `User ${username} has been removed`, 'success'); loadUsers(false); } else { showToast('Error', data.message || 'Failed to delete user', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } function openSelfPasswordModal() { document.getElementById('self-current-password').value = ''; document.getElementById('self-new-password').value = ''; document.getElementById('self-confirm-password').value = ''; document.getElementById('modal-self-password')?.classList.remove('hidden'); document.getElementById('self-current-password')?.focus(); } function closeSelfPasswordModal() { document.getElementById('modal-self-password')?.classList.add('hidden'); } async function submitSelfPassword(event) { event.preventDefault(); const oldPassword = document.getElementById('self-current-password')?.value || ''; const newPassword = document.getElementById('self-new-password')?.value || ''; const confirm = document.getElementById('self-confirm-password')?.value || ''; if (newPassword !== confirm) { showToast('Validation Error', 'New passwords do not match', 'warning'); return; } if (newPassword.length < 4) { showToast('Validation Error', 'Password must be at least 4 characters', 'warning'); return; } try { const res = await fetch(`${API_BASE}/auth/change-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }) }); const data = await res.json(); if (res.ok) { showToast('Password Changed', 'Your password has been changed successfully', 'success'); closeSelfPasswordModal(); } else { showToast('Error', data.message || 'Failed to change password', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ==================== MANAGEMENT SERVICES (/ip service) ==================== state.services = []; async function loadServices(manual = false) { try { const res = await fetch(`${API_BASE}/system/services`); if (!res.ok) throw new Error('Failed to load services'); const data = await res.json(); state.services = data || []; renderServicesTable(); updateCertificatesBadges(); if (manual) showToast('Services Updated', 'Service status synchronized', 'success'); } catch (err) { if (manual) showToast('Error', err.message, 'error'); } } function renderServicesTable() { const tbody = document.getElementById('services-table-body'); if (!tbody) return; if (!state.services || state.services.length === 0) { tbody.innerHTML = 'No services configured.'; return; } tbody.innerHTML = state.services.map(s => { const isOff = s.disabled; const statusBadge = isOff ? 'Disabled' : 'Active'; const toggleBtn = isOff ? `` : ``; let certBadge = ''; if (s.name === 'www-ssl') { if (s.certificate) { certBadge = `🔒 ${escapeHtml(s.certificate)}`; } else { certBadge = `⚠️ No Cert Selected`; } } const certArg = s.certificate ? `'${escapeHtml(s.certificate)}'` : "''"; return ` ${s.name} ${certBadge}
${s.port}
${statusBadge} ${s.comment || '-'}
${toggleBtn}
`; }).join(''); } function openEditServiceModal(serviceName, currentPort, isDisabled, currentCert = '') { document.getElementById('modal-service-name').value = serviceName; document.getElementById('modal-service-disabled').value = String(isDisabled); document.getElementById('modal-service-display-name').textContent = serviceName.toUpperCase(); document.getElementById('modal-service-port').value = currentPort; const hintEl = document.getElementById('modal-service-port-hint'); const warnEl = document.getElementById('modal-service-warning'); const certGroup = document.getElementById('modal-service-cert-group'); const certSelect = document.getElementById('modal-service-cert-select'); if (serviceName === 'www') { hintEl.textContent = 'Default HTTP web port: 80'; warnEl.classList.remove('hidden'); certGroup.classList.add('hidden'); } else if (serviceName === 'www-ssl') { hintEl.textContent = 'Default HTTPS web port: 443'; warnEl.classList.remove('hidden'); certGroup.classList.remove('hidden'); const populateCertOptions = (certs) => { certSelect.innerHTML = ''; (certs || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.name; opt.textContent = `${c.name} (${c.common_name})${c.is_ca ? ' [CA]' : ''}`; if (c.name === currentCert) opt.selected = true; certSelect.appendChild(opt); }); }; if (state.certificates && state.certificates.length > 0) { populateCertOptions(state.certificates); } else { loadCertificates(false).then(() => { populateCertOptions(state.certificates); }); } } else if (serviceName === 'telnet') { hintEl.textContent = 'Default Telnet console port: 8282'; warnEl.classList.add('hidden'); certGroup.classList.add('hidden'); } else if (serviceName === 'ssh') { hintEl.textContent = 'Default SSH port: 2222'; warnEl.classList.add('hidden'); certGroup.classList.add('hidden'); } else { hintEl.textContent = ''; warnEl.classList.add('hidden'); certGroup.classList.add('hidden'); } const modal = document.getElementById('modal-service-edit'); modal.classList.remove('hidden'); setTimeout(() => document.getElementById('modal-service-port').focus(), 50); } function closeEditServiceModal() { const modal = document.getElementById('modal-service-edit'); if (modal) modal.classList.add('hidden'); } async function submitSaveServicePort(event) { event.preventDefault(); const name = document.getElementById('modal-service-name').value; const disabled = document.getElementById('modal-service-disabled').value === 'true'; const port = parseInt(document.getElementById('modal-service-port').value, 10); if (isNaN(port) || port < 1 || port > 65535) { showToast('Invalid Port', 'Port must be a valid number between 1 and 65535', 'error'); return; } const certSelect = document.getElementById('modal-service-cert-select'); const certVal = (name === 'www-ssl' && certSelect) ? certSelect.value : ''; try { const res = await fetch(`${API_BASE}/system/services/${name}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: port, disabled: disabled, certificate: certVal }) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to update service settings'); } closeEditServiceModal(); showToast('Service Updated', `Service ${name.toUpperCase()} settings saved`, 'success'); await loadServices(); } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleServiceState(serviceName, newDisabled) { if (serviceName === 'www' && newDisabled) { if (!confirm("WARNING: Disabling the Web service ('www') will turn off this Web UI and REST API. You will only be able to re-enable it via SSH (port 2222) or Telnet (port 8282) using 'ip service enable www'. Continue?")) { return; } } if (serviceName === 'www-ssl' && !newDisabled) { const sslSvc = (state.services || []).find(s => s.name === 'www-ssl'); if (!sslSvc || !sslSvc.certificate) { showToast('Certificate Required', 'Please assign an SSL/TLS certificate to www-ssl before enabling HTTPS.', 'warning'); openEditServiceModal('www-ssl', sslSvc ? sslSvc.port : 443, false, ''); return; } } try { const res = await fetch(`${API_BASE}/system/services/${serviceName}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: newDisabled }) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to update service'); } showToast('Service Updated', `Service ${serviceName} is now ${newDisabled ? 'disabled' : 'enabled'}`, 'success'); await loadServices(); } catch (err) { showToast('Error', err.message, 'error'); } } // ==================== SYSTEM LOGS (/log) ==================== state.logs = []; let logsTimer = null; async function loadLogs(manual = false) { const limitEl = document.getElementById('logs-limit-select'); const limit = limitEl ? limitEl.value : '100'; try { const res = await fetch(`${API_BASE}/system/logs?limit=${limit}`); if (!res.ok) throw new Error('Failed to load logs'); const data = await res.json(); state.logs = data.logs || []; renderLogsTable(); if (manual) showToast('Logs Refreshed', `Fetched ${state.logs.length} log records`, 'success'); } catch (err) { if (manual) showToast('Error', err.message, 'error'); } // Setup auto-refresh if enabled and on logs tab if (!logsTimer && state.currentUser) { logsTimer = setInterval(() => { if (state.currentUser && !document.hidden && state.activeTab === 'logs') { const auto = document.getElementById('logs-auto-refresh'); if (auto && auto.checked) { loadLogs(false); } } }, 3000); } } function renderLogsTable() { const tbody = document.getElementById('logs-table-body'); if (!tbody) return; const search = (document.getElementById('logs-search-input')?.value || '').toLowerCase(); const topicFilter = (document.getElementById('logs-topic-filter')?.value || '').toLowerCase(); const filtered = state.logs.filter(entry => { if (topicFilter && !entry.topics.toLowerCase().includes(topicFilter)) { return false; } if (search && !entry.message.toLowerCase().includes(search) && !entry.topics.toLowerCase().includes(search)) { return false; } return true; }); if (filtered.length === 0) { tbody.innerHTML = 'No matching log entries found.'; return; } tbody.innerHTML = filtered.map(l => { let topicColor = 'bg-slate-800 text-slate-300 border-slate-700'; const topLower = l.topics.toLowerCase(); if (topLower.includes('auth')) { topicColor = 'bg-amber-500/20 text-amber-300 border-amber-500/30'; } else if (topLower.includes('dhcp')) { topicColor = 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30'; } else if (topLower.includes('route')) { topicColor = 'bg-blue-500/20 text-blue-300 border-blue-500/30'; } else if (topLower.includes('firewall')) { topicColor = 'bg-rose-500/20 text-rose-300 border-rose-500/30'; } else if (topLower.includes('error')) { topicColor = 'bg-red-500/20 text-red-300 border-red-500/30'; } else if (topLower.includes('system')) { topicColor = 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30'; } const d = new Date(l.time); const timeStr = d.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString('en-US', { hour12: false }); return ` ${l.id} ${timeStr} ${l.topics} ${escapeHtml(l.message)} `; }).join(''); } // ==================== CERTIFICATES & SSL/TLS (/certificate) ==================== state.certificates = []; async function loadCertificates(manual = false) { try { const res = await fetch(`${API_BASE}/certificates`); if (!res.ok) throw new Error('Failed to load certificates'); state.certificates = await res.json() || []; updateCertificatesBadges(); renderCertificatesTable(); if (manual) showToast('Certificates Updated', `Loaded ${state.certificates.length} certificates`, 'success'); } catch (err) { if (manual) showToast('Error', err.message, 'error'); } } function updateCertificatesBadges() { const total = (state.certificates || []).length; const caCount = (state.certificates || []).filter(c => c.is_ca).length; const bCount = document.getElementById('badge-certs-count'); if (bCount) bCount.innerText = total; const kpiTotal = document.getElementById('kpi-certs-total'); if (kpiTotal) kpiTotal.innerText = total; const kpiCA = document.getElementById('kpi-certs-ca'); if (kpiCA) kpiCA.innerText = caCount; const kpiHttps = document.getElementById('kpi-https-status'); if (kpiHttps) { const sslSvc = (state.services || []).find(s => s.name === 'www-ssl'); if (sslSvc) { if (!sslSvc.disabled) { kpiHttps.innerHTML = `Enabled (Port ${sslSvc.port})
Cert: ${escapeHtml(sslSvc.certificate || 'None')}
`; } else { kpiHttps.innerHTML = `Disabled (Port ${sslSvc.port})
Cert: ${escapeHtml(sslSvc.certificate || 'None')}
`; } } } } function renderCertificatesTable() { const tbody = document.getElementById('certs-table-body'); if (!tbody) return; if (!state.certificates || state.certificates.length === 0) { tbody.innerHTML = 'No certificates configured yet. Click "Create CA" or "Issue Cert" to begin.'; return; } tbody.innerHTML = state.certificates.map(c => { let typeBadge = ''; if (c.is_ca) { typeBadge = '🏛 CA'; } else if (c.type === 'acme') { typeBadge = '🔒 Let\'s Encrypt'; } else if (c.issuer && c.issuer !== c.common_name) { typeBadge = '📜 Signed'; } else { typeBadge = '📜 Self-Signed'; } const sansHtml = (c.sans && c.sans.length > 0) ? c.sans.map(s => `${escapeHtml(s)}`).join('') : '-'; const validTo = c.valid_to ? new Date(c.valid_to) : null; let validStr = '-'; let validBadge = ''; if (validTo && !isNaN(validTo.getTime())) { const now = new Date(); const daysLeft = Math.round((validTo - now) / (1000 * 60 * 60 * 24)); validStr = validTo.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); if (daysLeft < 0) { validBadge = '(Expired)'; } else if (daysLeft < 30) { validBadge = `(${daysLeft}d left)`; } else { validBadge = `(${daysLeft}d left)`; } } return ` ${typeBadge}
${escapeHtml(c.name)}
${escapeHtml(c.common_name)}
${escapeHtml(c.issuer || '-')} ${sansHtml} ${validStr} ${validBadge} ${c.type === 'acme' ? `
${c.renewal_error ? 'Renewal failed: ' + escapeHtml(c.renewal_error) : 'Automatic renewal enabled'}
` : ''}
${c.key_type || ''}
`; }).join(''); } // Create CA function openCreateCAModal() { document.getElementById('form-create-ca').reset(); document.getElementById('ca-days').value = '3650'; document.getElementById('ca-org').value = 'DebTik Network Appliance'; document.getElementById('ca-country').value = 'GR'; document.getElementById('ca-key-type').value = 'RSA-2048'; document.getElementById('modal-create-ca').classList.remove('hidden'); setTimeout(() => document.getElementById('ca-name').focus(), 50); } function closeCreateCAModal() { document.getElementById('modal-create-ca').classList.add('hidden'); } async function submitCreateCA(event) { event.preventDefault(); const payload = { name: document.getElementById('ca-name').value.trim(), common_name: document.getElementById('ca-cn').value.trim(), organization: document.getElementById('ca-org').value.trim(), country: document.getElementById('ca-country').value.trim(), days: parseInt(document.getElementById('ca-days').value, 10) || 3650, key_type: document.getElementById('ca-key-type').value }; try { const res = await fetch(`${API_BASE}/certificates/ca`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to create CA'); } closeCreateCAModal(); showToast('CA Created', `Root CA ${payload.name} generated successfully`, 'success'); await loadCertificates(); } catch (err) { showToast('Error', err.message, 'error'); } } // Create Cert function openCreateCertModal() { document.getElementById('form-create-cert').reset(); document.getElementById('cert-days').value = '730'; document.getElementById('cert-key-type').value = 'RSA-2048'; const caSelect = document.getElementById('cert-ca-select'); caSelect.innerHTML = ''; (state.certificates || []).filter(c => c.is_ca).forEach(ca => { const opt = document.createElement('option'); opt.value = ca.name; opt.textContent = `${ca.name} (${ca.common_name})`; caSelect.appendChild(opt); }); document.getElementById('modal-create-cert').classList.remove('hidden'); setTimeout(() => document.getElementById('cert-name').focus(), 50); } function closeCreateCertModal() { document.getElementById('modal-create-cert').classList.add('hidden'); } async function submitCreateCert(event) { event.preventDefault(); const rawSans = document.getElementById('cert-sans').value; const sansList = rawSans ? rawSans.split(',').map(s => s.trim()).filter(Boolean) : []; const caName = document.getElementById('cert-ca-select').value; const payload = { name: document.getElementById('cert-name').value.trim(), common_name: document.getElementById('cert-cn').value.trim(), ca_name: caName, sans: sansList, days: parseInt(document.getElementById('cert-days').value, 10) || 730, key_type: document.getElementById('cert-key-type').value, is_server: true }; try { const res = await fetch(`${API_BASE}/certificates/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to issue certificate'); } closeCreateCertModal(); showToast('Certificate Issued', `Certificate ${payload.name} generated successfully`, 'success'); await loadCertificates(); } catch (err) { showToast('Error', err.message, 'error'); } } // Let's Encrypt function openLetsEncryptModal() { document.getElementById('form-letsencrypt').reset(); document.getElementById('modal-letsencrypt').classList.remove('hidden'); setTimeout(() => document.getElementById('le-domain').focus(), 50); } function closeLetsEncryptModal() { document.getElementById('modal-letsencrypt').classList.add('hidden'); } async function submitLetsEncrypt(event) { event.preventDefault(); const domain = document.getElementById('le-domain').value.trim(); const email = document.getElementById('le-email').value.trim(); let name = document.getElementById('le-name').value.trim(); if (!name) name = domain.replace(/[^a-zA-Z0-9_-]/g, '-'); const staging = document.getElementById('le-staging').checked; const btn = document.getElementById('btn-submit-letsencrypt'); const origBtnHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` Issuing (may take up to 60s)...`; try { const res = await fetch(`${API_BASE}/certificates/letsencrypt`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name, domain: domain, email: email, staging: staging }) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Let\'s Encrypt challenge failed'); } closeLetsEncryptModal(); showToast('Let\'s Encrypt Issued', `Successfully issued trusted certificate for ${domain}`, 'success'); await loadCertificates(); } catch (err) { showToast('Let\'s Encrypt Error', err.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = origBtnHtml; } } // Import Cert function openImportCertModal() { document.getElementById('form-import-cert').reset(); document.getElementById('modal-import-cert').classList.remove('hidden'); setTimeout(() => document.getElementById('import-cert-name').focus(), 50); } function closeImportCertModal() { document.getElementById('modal-import-cert').classList.add('hidden'); } async function submitImportCert(event) { event.preventDefault(); const payload = { name: document.getElementById('import-cert-name').value.trim(), cert_pem: document.getElementById('import-cert-pem').value.trim(), key_pem: document.getElementById('import-key-pem').value.trim() }; try { const res = await fetch(`${API_BASE}/certificates/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to import certificate'); } closeImportCertModal(); showToast('Certificate Imported', `Imported ${payload.name} successfully`, 'success'); await loadCertificates(); } catch (err) { showToast('Error', err.message, 'error'); } } // View / Export Modal async function openViewCertModal(name) { const c = (state.certificates || []).find(x => x.name === name); if (!c) return; document.getElementById('view-cert-modal-title').textContent = `Certificate: ${c.name}`; document.getElementById('view-cert-cn').textContent = c.common_name || '-'; document.getElementById('view-cert-issuer').textContent = c.issuer || '-'; document.getElementById('view-cert-type').textContent = `${c.is_ca ? 'CA' : 'Server/Client'} (${c.key_type || 'Unknown'})`; document.getElementById('view-cert-validto').textContent = c.valid_to ? new Date(c.valid_to).toLocaleString() : '-'; document.getElementById('view-cert-sans').textContent = (c.sans && c.sans.length > 0) ? c.sans.join(', ') : 'None'; document.getElementById('view-cert-fp').textContent = c.fingerprint || '-'; document.getElementById('view-cert-pem-text').value = 'Loading PEM...'; document.getElementById('view-key-pem-text').value = 'Loading Key...'; document.getElementById('modal-view-cert').classList.remove('hidden'); try { const res = await fetch(`${API_BASE}/certificates/${encodeURIComponent(name)}/export`); if (!res.ok) throw new Error('Failed to export certificate PEM'); const data = await res.json(); document.getElementById('view-cert-pem-text').value = data.cert_pem || ''; document.getElementById('view-key-pem-text').value = data.key_pem || '(Private key not available)'; } catch (err) { document.getElementById('view-cert-pem-text').value = 'Error loading certificate PEM: ' + err.message; document.getElementById('view-key-pem-text').value = ''; } } function closeViewCertModal() { document.getElementById('modal-view-cert').classList.add('hidden'); } function copyCertPEM() { const text = document.getElementById('view-cert-pem-text').value; if (!text) return; navigator.clipboard.writeText(text).then(() => { showToast('Copied', 'Certificate PEM copied to clipboard', 'info'); }).catch(() => { showToast('Error', 'Failed to copy to clipboard', 'error'); }); } function copyKeyPEM() { const text = document.getElementById('view-key-pem-text').value; if (!text) return; navigator.clipboard.writeText(text).then(() => { showToast('Copied', 'Private Key PEM copied to clipboard', 'info'); }).catch(() => { showToast('Error', 'Failed to copy to clipboard', 'error'); }); } async function deleteCertificate(name) { if (!confirm(`Are you sure you want to delete certificate "${name}"? This action cannot be undone.`)) { return; } try { const res = await fetch(`${API_BASE}/certificates/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to delete certificate'); } showToast('Certificate Deleted', `Certificate "${name}" removed`, 'success'); await loadCertificates(); } catch (err) { showToast('Error', err.message, 'error'); } } // Refresh immediately when returning to a backgrounded controller tab. document.addEventListener('visibilitychange', () => { if (!state.currentUser) return; if (document.hidden) { if (trafficEventSource) trafficEventSource.close(); trafficEventSource = null; if (trafficPollFallback) clearInterval(trafficPollFallback); trafficPollFallback = null; } else { fetchData(false); initLiveTrafficStream(); if (state.activeTab === 'bwtest') loadBwTestView(); } }); async function loadUpdateStatus(populate = true) { try { const res = await fetch(`${API_BASE}/update/status`); if (!res.ok) throw new Error(await responseError(res)); const data = await res.json(); if (populate && document.activeElement?.id !== 'update-repository') document.getElementById('update-repository').value = data.repository || ''; if (populate && document.activeElement?.id !== 'update-tls-pin') document.getElementById('update-tls-pin').value = data.tls_key_pin || ''; document.getElementById('update-installed').textContent = data.installed_version || 'unknown'; document.getElementById('update-state').textContent = data.state || 'idle'; document.getElementById('update-log').textContent = data.log || ''; } catch (err) { showToast('Update Status', err.message, 'error'); } } async function saveUpdateConfig() { try { const repository = document.getElementById('update-repository').value.trim(); const branch = 'master'; const tls_key_pin = document.getElementById('update-tls-pin').value.trim(); const res = await fetch(`${API_BASE}/update/config`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({repository,branch,tls_key_pin})}); if (!res.ok) throw new Error(await responseError(res)); document.getElementById('update-install-button').disabled = true; document.getElementById('update-available').textContent = '—'; showToast('Update Source Saved', 'Repository and branch saved', 'success'); await loadUpdateStatus(false); } catch (err) { showToast('Update Source', err.message, 'error'); } } async function checkUpdate() { try { const res = await fetch(`${API_BASE}/update/check`, {method:'POST'}); if (!res.ok) throw new Error(await responseError(res)); const data = await res.json(); document.getElementById('update-available').textContent = data.available_version; document.getElementById('update-install-button').disabled = !data.update_available; showToast('Git Check', data.update_available ? 'An update is available' : 'No newer version available', 'info'); } catch (err) { showToast('Git Check Failed', err.message, 'error'); } } async function startUpdate(action) { if (!confirm(action === 'rollback' ? 'Restore the previous version and restart DebTik?' : 'Install the newer release version and restart DebTik?')) return; try { const res = await fetch(`${API_BASE}/update/${action}`, {method:'POST'}); if (!res.ok) throw new Error(await responseError(res)); showToast('Update Started', 'The service will restart; reconnect after a few seconds', 'info'); await loadUpdateStatus(false); } catch (err) { showToast('Update Failed', err.message, 'error'); } } async function resetCurrentRuleCounters() { return resetFirewallRuleCounter('filter', resetRuleCounterDisplay); } async function resetCurrentNATRuleCounters() { return resetFirewallRuleCounter('nat', resetNATCounterDisplay); } async function resetCurrentMangleRuleCounters() { return resetFirewallRuleCounter('mangle', resetMangleCounterDisplay); } function safeFirewallRow(row) { const result = {...row}; for (const [key,value] of Object.entries(result)) if (typeof value === 'string') result[key] = escapeHtml(value); result.id = encodeURIComponent(String(row.id || '')).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); return result; } function setFirewallDays(prefix, days) { const selected = days ? days.toLowerCase().split(',') : ['sun','mon','tue','wed','thu','fri','sat']; for (const d of ['sun','mon','tue','wed','thu','fri','sat']) { const el = document.getElementById(prefix+'-day-'+d); if (el) el.checked = selected.includes(d); } } function getFirewallDays(prefix) { if (document.getElementById(prefix+'-field-days')?.classList.contains('hidden')) return ''; const selected = ['sun','mon','tue','wed','thu','fri','sat'].filter(d => document.getElementById(prefix+'-day-'+d)?.checked); if (!selected.length) throw new Error('Select at least one weekday, or remove the Days condition.'); return selected.join(','); } async function resetFirewallRuleCounter(kind, resetDisplay) { const id = document.getElementById(kind+'-modal-id')?.value; if (!id) { resetDisplay(); return; } try { const response = await fetch(`${API_BASE}/firewall/${kind}/${encodeURIComponent(id)}/reset-counters`, {method:'POST'}); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Counter reset failed'); } resetDisplay(); await loadFirewall(false); } catch(error) { showToast('Error', error.message, 'error'); } } function reviewLegacyFirewallSettings(kind, id) { if (!id) return true; const rows = kind === 'filter' ? state.filterRules : kind === 'nat' ? state.natRules : state.mangleRules; const rule = (rows || []).find(r => r.id === id); if (!rule) return true; const unsupported = ['layer7_protocol','connection_bytes','connection_rate','pcc','in_bridge_port_list','out_bridge_port_list','tls_host','ingress_priority','priority','ipv4_options','dst_limit','psd','hotspot','route','sniff_target']; const present = unsupported.filter(key => rule[key]); if (!present.length) return true; return confirm('This saved rule contains unsupported settings: '+present.join(', ')+'. Saving removes them. Review the remaining match conditions before continuing.'); } function refreshFirewallRejectTypes() { const select=document.getElementById('filter-modal-rejectwith'); if(!select) return; const old=select.value; const ipv6=document.getElementById('filter-modal-family')?.value==='6'; const types=ipv6 ? ['icmp6-port-unreachable','icmp6-no-route','icmp6-adm-prohibited','icmp6-addr-unreachable','tcp-reset'] : ['icmp-port-unreachable','icmp-net-unreachable','icmp-host-unreachable','icmp-proto-unreachable','icmp-net-prohibited','icmp-host-prohibited','icmp-admin-prohibited','tcp-reset']; select.replaceChildren(...types.map(value=>{const option=document.createElement('option');option.value=value;option.textContent=value;return option})); if(types.includes(old))select.value=old; } function setFirewallActionSelection(kind, value) { const select=document.getElementById(kind+'-modal-action'); if(!select)return; if(!Array.from(select.options).some(option=>option.value===value)) { const option=document.createElement('option');option.value=value;option.textContent='Unsupported saved action: '+value;option.disabled=true;select.appendChild(option); } select.value=value; }