// 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: [],
vxlanTunnels: [],
vxlanVteps: [],
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,
sdwanGroups: [],
sdwanTelemetry: 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' },
sdwan: { title: 'Software-Defined WAN (SD-WAN)', sub: 'Multi-link aggregation, traffic load balancing & quality SLA health monitoring' },
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' },
rip: { title: 'RIP Dynamic Routing', sub: 'Routing Information Protocol (RIPv2 IPv4 & RIPng IPv6) hop-count distance-vector routing' },
bfd: { title: 'BFD Fast Session Detection', sub: 'Bidirectional Forwarding Detection (BFD) sub-second link failure detection for BGP and OSPF' },
babel: { title: 'Babel Dynamic Routing', sub: 'Babel loop-avoiding distance-vector routing for wired and wireless mesh networks (RFC 8966)' },
radv: { title: 'IPv6 Router Advertisements (RAdv)', sub: 'BIRD 2 ICMPv6 Router Advertisement daemon for IPv6 SLAAC autoconfiguration and RDNSS' },
'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' },
vxlan: { title: 'VXLAN Tunnels', sub: 'Virtual Extensible LAN (RFC 7348) Layer 2 overlay networks and static VTEPs' },
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' },
reverse_proxy: { title: 'Reverse Proxy & Gateway', sub: 'HTTP/HTTPS Path Routing, Domain Forwarding & SSL Termination' },
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 === 'sdwan') loadSDWAN();
if (tabName === 'bgp') {
loadDynamicRouting();
switchBgpSubTab(state.activeBgpSubTab || 'bgp-peers');
}
if (tabName === 'ospf') {
loadDynamicRouting();
switchOspfTab(state.activeOspfTab || 'instances');
}
if (tabName === 'rip') {
loadDynamicRouting();
switchRipTab(state.activeRipTab || 'instances');
}
if (tabName === 'bfd') {
loadDynamicRouting();
switchBfdTab(state.activeBfdTab || 'config');
}
if (tabName === 'babel') {
loadDynamicRouting();
switchBabelTab(state.activeBabelTab || 'instances');
}
if (tabName === 'radv') {
loadDynamicRouting();
switchRadvTab(state.activeRadvTab || 'instances');
}
if (tabName === 'routing-filters') loadDynamicRouting();
if (tabName === 'dynamic-routing') loadDynamicRouting();
if (tabName === 'ppp') loadPPP();
if (tabName === 'eoip') loadEoIP();
if (tabName === 'vxlan') loadVXLAN();
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 === 'reverse_proxy') loadReverseProxy();
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' || state.activeTab === 'rip' || state.activeTab === 'bfd' || state.activeTab === 'babel' || state.activeTab === 'radv') await loadDynamicRouting(false);
if (state.activeTab === 'ppp') await loadPPP(false);
if (state.activeTab === 'eoip') await loadEoIP(false);
if (state.activeTab === 'vxlan') await loadVXLAN(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 === 'reverse_proxy') await loadReverseProxy(false);
if (state.activeTab === 'sdwan') await loadSDWAN(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 bVXLAN = document.getElementById('badge-vxlan-count');
if (bVXLAN) bVXLAN.innerText = v.total_vxlan || 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);
setDotBadge('badge-rip-dot', d.rip_running);
setDotBadge('badge-bfd-dot', d.bfd_running);
setDotBadge('badge-babel-dot', d.babel_running);
setDotBadge('badge-radv-dot', d.radv_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.ospfLiveInterfaces || state.ospfTemplates || []).length;
const bRip = document.getElementById('badge-rip-count');
if (bRip) bRip.innerText = (state.ripInterfaces || []).length;
const bBfd = document.getElementById('badge-bfd-count');
if (bBfd) bBfd.innerText = (state.bfdInterfaces || []).length;
const bBabel = document.getElementById('badge-babel-count');
if (bBabel) bBabel.innerText = (state.babelInterfaces || []).length;
const bRadv = document.getElementById('badge-radv-count');
if (bRadv) bRadv.innerText = (state.radvInterfaces || []).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.
` : `
| VLAN ID |
Interface Device |
Subnet Gateway |
Role / Alias |
MTU |
Throughput |
Status |
Actions |
${childVlans.map(v => {
const isVlanUp = v.state === 'UP';
return `
|
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 ``;
})()}
|
`;
}).join('')}
`}
`;
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);
});
if (state.sdwanGroups && state.sdwanGroups.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'SD-WAN Groups';
state.sdwanGroups.forEach(g => {
const opt = document.createElement('option');
opt.value = g.name;
opt.innerText = `${g.name} [SD-WAN: ${g.mode}]`;
if (g.name === currentVal) opt.selected = true;
optgroup.appendChild(opt);
});
sel.appendChild(optgroup);
}
}
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]');
}
}
const FW_NATIVE_CHAINS = {
filter: [
{ id: 'forward', label: 'FORWARD (routed through router)' },
{ id: 'input', label: 'INPUT (destined to this router)' },
{ id: 'output', label: 'OUTPUT (generated by this router)' }
],
nat: [
{ id: 'postrouting', label: 'POSTROUTING (outgoing after routing — SNAT / MASQUERADE)' },
{ id: 'prerouting', label: 'PREROUTING (incoming before routing — DNAT / REDIRECT)' },
{ id: 'output', label: 'OUTPUT (locally generated — DNAT / REDIRECT)' },
{ id: 'input', label: 'INPUT (destined to this machine — SNAT)' }
],
mangle: [
{ id: 'prerouting', label: 'PREROUTING (incoming before routing)' },
{ id: 'input', label: 'INPUT (destined to this machine)' },
{ id: 'forward', label: 'FORWARD (routed through router)' },
{ id: 'output', label: 'OUTPUT (generated by this machine)' },
{ id: 'postrouting', label: 'POSTROUTING (outgoing after routing)' }
]
};
function populateChainDropdown(table, currentChain) {
const sel = document.getElementById(`${table}-modal-chain-select`);
if (!sel) return;
const cur = (currentChain || '').trim();
const lower = cur.toLowerCase();
const rulesKey = table === 'filter' ? 'filterRules' : table === 'nat' ? 'natRules' : 'mangleRules';
const rules = state[rulesKey] || [];
const nativeIds = FW_NATIVE_CHAINS[table].map(c => c.id);
const customChains = new Set();
rules.forEach(r => {
if (r.chain) {
const c = r.chain.trim();
const l = c.toLowerCase();
if (!nativeIds.includes(l) && l !== 'srcnat' && l !== 'dstnat' && !['forward', 'input', 'output', 'prerouting', 'postrouting'].includes(l)) {
customChains.add(c);
}
}
});
const isCurrentCustom = cur && !nativeIds.includes(lower) && lower !== 'srcnat' && lower !== 'dstnat' && !['forward', 'input', 'output', 'prerouting', 'postrouting'].includes(lower);
if (isCurrentCustom) {
customChains.add(cur);
}
let html = '';
FW_NATIVE_CHAINS[table].forEach(c => {
html += ``;
});
if (customChains.size > 0) {
html += ``;
}
html += ``;
sel.innerHTML = html;
if (isCurrentCustom) {
sel.value = cur;
} else if (nativeIds.includes(lower)) {
sel.value = lower;
} else if (table === 'nat' && (lower === 'srcnat' || lower === 'dstnat')) {
sel.value = lower === 'dstnat' ? 'prerouting' : 'postrouting';
} else {
sel.value = nativeIds[0];
}
const customWrap = document.getElementById(`${table}-modal-custom-wrap`);
const customInput = document.getElementById(`${table}-modal-custom-input`);
if (isCurrentCustom || sel.value === '__custom__') {
if (customWrap) customWrap.classList.remove('hidden');
if (customInput) customInput.value = cur;
} else {
if (customWrap) customWrap.classList.add('hidden');
if (customInput) customInput.value = '';
}
const chainInput = document.getElementById(`${table}-modal-chain`);
if (chainInput) chainInput.value = cur || sel.value;
}
function syncFilterChainFromSelect() {
const sel = document.getElementById('filter-modal-chain-select');
const customWrap = document.getElementById('filter-modal-custom-wrap');
const customInput = document.getElementById('filter-modal-custom-input');
const chainInput = document.getElementById('filter-modal-chain');
if (!sel || !chainInput) return;
if (sel.value === '__custom__') {
if (customWrap) customWrap.classList.remove('hidden');
if (customInput) {
customInput.value = '';
customInput.focus();
}
chainInput.value = '';
} else {
if (customWrap) customWrap.classList.add('hidden');
chainInput.value = sel.value;
}
handleFilterChainChange();
}
function syncFilterCustomChainInput() {
const customInput = document.getElementById('filter-modal-custom-input');
const chainInput = document.getElementById('filter-modal-chain');
if (customInput && chainInput) {
chainInput.value = customInput.value.trim();
}
handleFilterChainChange();
}
function handleFilterChainChange() {
const chainInput = document.getElementById('filter-modal-chain');
const chain = (chainInput?.value || 'forward').trim().toLowerCase();
const sel = document.getElementById('filter-modal-chain-select');
if (sel && sel.value !== chain && sel.value !== '__custom__') {
if (['forward', 'input', 'output'].includes(chain)) {
sel.value = chain;
}
}
const hintEl = document.getElementById('filter-chain-hint');
if (hintEl) {
if (chain === 'forward') {
hintEl.textContent = 'FORWARD: packets routed through this router (accepts both In and Out interfaces).';
} else if (chain === 'input') {
hintEl.textContent = 'INPUT: packets destined to this router (In-Interface only; Out-Interface is invalid in iptables).';
} else if (chain === 'output') {
hintEl.textContent = 'OUTPUT: packets generated by this router (Out-Interface only; In-Interface is invalid in iptables).';
} else {
hintEl.textContent = `Custom chain "${chain}": reached via an explicit JUMP from a built-in chain.`;
}
}
const rowIn = document.getElementById('fw-row-iniface');
const rowInList = document.getElementById('fw-row-inifacelist');
const rowOut = document.getElementById('fw-row-outiface');
const rowOutList = document.getElementById('fw-row-outifacelist');
const rowInBridge = document.getElementById('fw-row-inbridgeport');
const rowOutBridge = document.getElementById('fw-row-outbridgeport');
const rowSrcMac = document.getElementById('fw-row-srcmac');
if (chain === 'input') {
if (rowIn) rowIn.classList.remove('hidden');
if (rowInList) rowInList.classList.remove('hidden');
if (rowOut) rowOut.classList.add('hidden');
if (rowOutList) rowOutList.classList.add('hidden');
if (rowInBridge) rowInBridge.classList.remove('hidden');
if (rowOutBridge) rowOutBridge.classList.add('hidden');
if (rowSrcMac) rowSrcMac.classList.remove('hidden');
} else if (chain === 'output') {
if (rowIn) rowIn.classList.add('hidden');
if (rowInList) rowInList.classList.add('hidden');
if (rowOut) rowOut.classList.remove('hidden');
if (rowOutList) rowOutList.classList.remove('hidden');
if (rowInBridge) rowInBridge.classList.add('hidden');
if (rowOutBridge) rowOutBridge.classList.remove('hidden');
if (rowSrcMac) rowSrcMac.classList.add('hidden');
} else {
if (rowIn) rowIn.classList.remove('hidden');
if (rowInList) rowInList.classList.remove('hidden');
if (rowOut) rowOut.classList.remove('hidden');
if (rowOutList) rowOutList.classList.remove('hidden');
if (rowInBridge) rowInBridge.classList.remove('hidden');
if (rowOutBridge) rowOutBridge.classList.remove('hidden');
if (rowSrcMac) rowSrcMac.classList.remove('hidden');
}
checkFilterActionChainCompatibility();
}
function checkFilterActionChainCompatibility() {
const chain = (document.getElementById('filter-modal-chain')?.value || 'forward').trim().toLowerCase();
const warnBox = document.getElementById('filter-action-incompatible-warn');
const warnMsg = document.getElementById('filter-action-incompatible-msg');
if (!warnBox || !warnMsg) return;
if (chain === 'prerouting' || chain === 'postrouting') {
warnMsg.textContent = `Chain "${chain.toUpperCase()}" is not available in the Filter table. Filter only supports INPUT, FORWARD, OUTPUT or custom chains.`;
warnBox.classList.remove('hidden');
} else {
warnBox.classList.add('hidden');
}
}
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';
populateChainDropdown('filter', 'forward');
handleFilterChainChange();
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 || '';
const filterChain = r.chain || 'forward';
document.getElementById('filter-modal-chain').value = filterChain;
populateChainDropdown('filter', filterChain);
handleFilterChainChange();
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 || '').trim();
const chainLower = chain.toLowerCase();
if (!chain) { switchFwRuleTab('general'); showToast('Filter Rule', 'A valid chain is required.', 'error'); document.getElementById('filter-modal-chain').focus(); return; }
if (chainLower === 'prerouting' || chainLower === 'postrouting') {
switchFwRuleTab('general');
showToast('Invalid Chain', `Chain "${chain.toUpperCase()}" is not available in the Filter table in iptables.`, 'error');
return;
}
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;
}
let iniface = getFwFieldVal('iniface', true);
let outiface = getFwFieldVal('outiface', true);
let inifacelist = getFwFieldVal('inifacelist', true);
let outifacelist = getFwFieldVal('outifacelist', true);
if (chainLower === 'input') {
outiface = '';
outifacelist = '';
} else if (chainLower === 'output') {
iniface = '';
inifacelist = '';
}
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: chainLower === 'output' ? '' : getFwFieldVal('srcmac', true),
out_bridge_port: chainLower === 'input' ? '' : getFwFieldVal('outbridgeport', false),
in_bridge_port: chainLower === 'output' ? '' : 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 syncNATChainFromSelect() {
const sel = document.getElementById('nat-modal-chain-select');
const customWrap = document.getElementById('nat-modal-custom-wrap');
const customInput = document.getElementById('nat-modal-custom-input');
const chainInput = document.getElementById('nat-modal-chain');
if (!sel || !chainInput) return;
if (sel.value === '__custom__') {
if (customWrap) customWrap.classList.remove('hidden');
if (customInput) {
customInput.value = '';
customInput.focus();
}
chainInput.value = '';
} else {
if (customWrap) customWrap.classList.add('hidden');
chainInput.value = sel.value;
const actEl = document.getElementById('nat-modal-action');
if (actEl) {
const curAct = actEl.value;
if (sel.value === 'prerouting' && ['masquerade', 'src-nat'].includes(curAct)) {
actEl.value = 'dst-nat';
} else if (sel.value === 'postrouting' && ['dst-nat', 'redirect'].includes(curAct)) {
actEl.value = 'masquerade';
}
}
}
handleNATChainChange();
}
function syncNATCustomChainInput() {
const customInput = document.getElementById('nat-modal-custom-input');
const chainInput = document.getElementById('nat-modal-chain');
if (customInput && chainInput) {
chainInput.value = customInput.value.trim();
}
handleNATChainChange();
}
function handleNATChainChange() {
const chainInput = document.getElementById('nat-modal-chain');
const rawChain = (chainInput?.value || 'postrouting').trim();
const chain = nativeNATChain(rawChain).toLowerCase();
const sel = document.getElementById('nat-modal-chain-select');
if (sel && sel.value !== chain && sel.value !== '__custom__') {
if (['postrouting', 'prerouting', 'output', 'input'].includes(chain)) {
sel.value = chain;
}
}
const hintEl = document.getElementById('nat-chain-hint');
if (hintEl) {
if (chain === 'prerouting') {
hintEl.textContent = 'PREROUTING: destination NAT (DNAT / REDIRECT) before routing decision (In-Interface only).';
} else if (chain === 'postrouting') {
hintEl.textContent = 'POSTROUTING: source NAT (SNAT / MASQUERADE) after routing decision (Out-Interface only).';
} else if (chain === 'output') {
hintEl.textContent = 'OUTPUT: destination NAT for locally generated packets (Out-Interface only).';
} else if (chain === 'input') {
hintEl.textContent = 'INPUT: source NAT for packets destined to local sockets (In-Interface only).';
} else {
hintEl.textContent = `Custom chain "${rawChain}": user-defined NAT chain reached via JUMP.`;
}
}
const rowIn = document.getElementById('nat-row-iniface');
const rowInList = document.getElementById('nat-row-inifacelist');
const rowOut = document.getElementById('nat-row-outiface');
const rowOutList = document.getElementById('nat-row-outifacelist');
const rowInBridge = document.getElementById('nat-row-inbridgeport');
const rowOutBridge = document.getElementById('nat-row-outbridgeport');
const rowSrcMac = document.getElementById('nat-row-srcmac');
if (chain === 'prerouting' || chain === 'input') {
if (rowIn) rowIn.classList.remove('hidden');
if (rowInList) rowInList.classList.remove('hidden');
if (rowOut) rowOut.classList.add('hidden');
if (rowOutList) rowOutList.classList.add('hidden');
if (rowInBridge) rowInBridge.classList.remove('hidden');
if (rowOutBridge) rowOutBridge.classList.add('hidden');
if (rowSrcMac) rowSrcMac.classList.remove('hidden');
} else if (chain === 'postrouting' || chain === 'output') {
if (rowIn) rowIn.classList.add('hidden');
if (rowInList) rowInList.classList.add('hidden');
if (rowOut) rowOut.classList.remove('hidden');
if (rowOutList) rowOutList.classList.remove('hidden');
if (rowInBridge) rowInBridge.classList.add('hidden');
if (rowOutBridge) rowOutBridge.classList.remove('hidden');
if (rowSrcMac) rowSrcMac.classList.add('hidden');
} else {
if (rowIn) rowIn.classList.remove('hidden');
if (rowInList) rowInList.classList.remove('hidden');
if (rowOut) rowOut.classList.remove('hidden');
if (rowOutList) rowOutList.classList.remove('hidden');
if (rowInBridge) rowInBridge.classList.remove('hidden');
if (rowOutBridge) rowOutBridge.classList.remove('hidden');
if (rowSrcMac) rowSrcMac.classList.remove('hidden');
}
checkNATActionChainCompatibility();
toggleNATActionFields();
}
function checkNATActionChainCompatibility() {
const rawChain = (document.getElementById('nat-modal-chain')?.value || 'postrouting').trim();
const chain = nativeNATChain(rawChain).toLowerCase();
const action = (document.getElementById('nat-modal-action')?.value || '').toLowerCase();
const warnBox = document.getElementById('nat-action-incompatible-warn');
const warnMsg = document.getElementById('nat-action-incompatible-msg');
if (!warnBox || !warnMsg) return;
let msg = '';
if (['dst-nat', 'redirect'].includes(action)) {
if (chain !== 'prerouting' && chain !== 'output') {
msg = `${action === 'dst-nat' ? 'DNAT' : 'REDIRECT'} is only valid in PREROUTING or OUTPUT chains in iptables (current chain: ${chain.toUpperCase()}).`;
}
} else if (action === 'masquerade') {
if (chain !== 'postrouting') {
msg = `MASQUERADE is only valid in the POSTROUTING chain in iptables (current chain: ${chain.toUpperCase()}).`;
}
} else if (action === 'src-nat') {
if (chain !== 'postrouting' && chain !== 'input') {
msg = `SNAT is only valid in POSTROUTING or INPUT chains in iptables (current chain: ${chain.toUpperCase()}).`;
}
}
if (msg) {
warnMsg.textContent = msg;
warnBox.classList.remove('hidden');
} else {
warnBox.classList.add('hidden');
}
}
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();
checkNATActionChainCompatibility();
}
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';
populateChainDropdown('nat', 'postrouting');
handleNATChainChange();
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 || '';
const natChain = nativeNATChain(r.chain || 'postrouting');
document.getElementById('nat-modal-chain').value = natChain;
populateChainDropdown('nat', natChain);
handleNATChainChange();
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 || '').trim();
const chainLower = nativeNATChain(chain).toLowerCase();
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);
let iniface = getNATFieldVal('iniface', true);
let outiface = getNATFieldVal('outiface', true);
let inifacelist = getNATFieldVal('inifacelist', true);
let outifacelist = getNATFieldVal('outifacelist', true);
if (chainLower === 'prerouting' || chainLower === 'input') {
outiface = '';
outifacelist = '';
} else if (chainLower === 'postrouting' || chainLower === 'output') {
iniface = '';
inifacelist = '';
}
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) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter a chain.', 'error'); document.getElementById('nat-modal-chain').focus(); return; }
if (chainLower === 'forward') { switchNATRuleTab('general'); showToast('Invalid Chain', 'Chain FORWARD is not valid in the NAT table in iptables.', 'error'); return; }
if (['dst-nat', 'redirect'].includes(action) && chainLower !== 'prerouting' && chainLower !== 'output') {
switchNATRuleTab('general');
showToast('Invalid Target for Chain', `${action === 'dst-nat' ? 'DNAT' : 'REDIRECT'} requires PREROUTING or OUTPUT chain in iptables.`, 'error');
return;
}
if (action === 'masquerade' && chainLower !== 'postrouting') {
switchNATRuleTab('general');
showToast('Invalid Target for Chain', 'MASQUERADE requires POSTROUTING chain in iptables.', 'error');
return;
}
if (action === 'src-nat' && chainLower !== 'postrouting' && chainLower !== 'input') {
switchNATRuleTab('general');
showToast('Invalid Target for Chain', 'SNAT requires POSTROUTING or INPUT chain in iptables.', 'error');
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: (chainLower === 'output' || chainLower === 'postrouting') ? '' : getNATFieldVal('srcmac', true),
out_bridge_port: (chainLower === 'input' || chainLower === 'prerouting') ? '' : getNATFieldVal('outbridgeport', false),
in_bridge_port: (chainLower === 'output' || chainLower === 'postrouting') ? '' : 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 syncMangleChainFromSelect() {
const sel = document.getElementById('mangle-modal-chain-select');
const customWrap = document.getElementById('mangle-modal-custom-wrap');
const customInput = document.getElementById('mangle-modal-custom-input');
const chainInput = document.getElementById('mangle-modal-chain');
if (!sel || !chainInput) return;
if (sel.value === '__custom__') {
if (customWrap) customWrap.classList.remove('hidden');
if (customInput) {
customInput.value = '';
customInput.focus();
}
chainInput.value = '';
} else {
if (customWrap) customWrap.classList.add('hidden');
chainInput.value = sel.value;
}
handleMangleChainChange();
}
function syncMangleCustomChainInput() {
const customInput = document.getElementById('mangle-modal-custom-input');
const chainInput = document.getElementById('mangle-modal-chain');
if (customInput && chainInput) {
chainInput.value = customInput.value.trim();
}
handleMangleChainChange();
}
function handleMangleChainChange() {
const chainInput = document.getElementById('mangle-modal-chain');
const rawChain = (chainInput?.value || 'prerouting').trim();
const chain = rawChain.toLowerCase();
const sel = document.getElementById('mangle-modal-chain-select');
if (sel && sel.value !== chain && sel.value !== '__custom__') {
if (['prerouting', 'input', 'forward', 'output', 'postrouting'].includes(chain)) {
sel.value = chain;
}
}
const hintEl = document.getElementById('mangle-chain-hint');
if (hintEl) {
if (chain === 'prerouting') {
hintEl.textContent = 'PREROUTING: packets altered before routing decision (In-Interface only).';
} else if (chain === 'input') {
hintEl.textContent = 'INPUT: packets altered before local socket delivery (In-Interface only).';
} else if (chain === 'forward') {
hintEl.textContent = 'FORWARD: transit packets routed through this router (accepts both In and Out interfaces).';
} else if (chain === 'output') {
hintEl.textContent = 'OUTPUT: locally generated packets before routing decision (Out-Interface only).';
} else if (chain === 'postrouting') {
hintEl.textContent = 'POSTROUTING: packets altered after routing decision before transmission (Out-Interface only; CLASSIFY supported).';
} else {
hintEl.textContent = `Custom chain "${rawChain}": user-defined Mangle chain reached via JUMP.`;
}
}
const rowIn = document.getElementById('mangle-row-iniface');
const rowInList = document.getElementById('mangle-row-inifacelist');
const rowOut = document.getElementById('mangle-row-outiface');
const rowOutList = document.getElementById('mangle-row-outifacelist');
const rowInBridge = document.getElementById('mangle-row-inbridgeport');
const rowOutBridge = document.getElementById('mangle-row-outbridgeport');
const rowSrcMac = document.getElementById('mangle-row-srcmac');
if (chain === 'prerouting' || chain === 'input') {
if (rowIn) rowIn.classList.remove('hidden');
if (rowInList) rowInList.classList.remove('hidden');
if (rowOut) rowOut.classList.add('hidden');
if (rowOutList) rowOutList.classList.add('hidden');
if (rowInBridge) rowInBridge.classList.remove('hidden');
if (rowOutBridge) rowOutBridge.classList.add('hidden');
if (rowSrcMac) rowSrcMac.classList.remove('hidden');
} else if (chain === 'output' || chain === 'postrouting') {
if (rowIn) rowIn.classList.add('hidden');
if (rowInList) rowInList.classList.add('hidden');
if (rowOut) rowOut.classList.remove('hidden');
if (rowOutList) rowOutList.classList.remove('hidden');
if (rowInBridge) rowInBridge.classList.add('hidden');
if (rowOutBridge) rowOutBridge.classList.remove('hidden');
if (rowSrcMac) rowSrcMac.classList.add('hidden');
} else {
if (rowIn) rowIn.classList.remove('hidden');
if (rowInList) rowInList.classList.remove('hidden');
if (rowOut) rowOut.classList.remove('hidden');
if (rowOutList) rowOutList.classList.remove('hidden');
if (rowInBridge) rowInBridge.classList.remove('hidden');
if (rowOutBridge) rowOutBridge.classList.remove('hidden');
if (rowSrcMac) rowSrcMac.classList.remove('hidden');
}
checkMangleActionChainCompatibility();
toggleMangleActionFields();
}
function checkMangleActionChainCompatibility() {
const rawChain = (document.getElementById('mangle-modal-chain')?.value || 'prerouting').trim();
const chain = rawChain.toLowerCase();
const action = (document.getElementById('mangle-modal-action')?.value || '').toLowerCase();
const warnBox = document.getElementById('mangle-action-incompatible-warn');
const warnMsg = document.getElementById('mangle-action-incompatible-msg');
if (!warnBox || !warnMsg) return;
let msg = '';
if (action === 'set-priority' && chain !== 'postrouting') {
msg = 'CLASSIFY (set-priority) requires the POSTROUTING chain in iptables.';
}
if (msg) {
warnMsg.textContent = msg;
warnBox.classList.remove('hidden');
} else {
warnBox.classList.add('hidden');
}
}
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);
}
checkMangleActionChainCompatibility();
}
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';
populateChainDropdown('mangle', 'prerouting');
handleMangleChainChange();
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 || '';
const mangleChain = r.chain || 'prerouting';
document.getElementById('mangle-modal-chain').value = mangleChain;
populateChainDropdown('mangle', mangleChain);
handleMangleChainChange();
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 || '').trim();
const chainLower = chain.toLowerCase();
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;
}
let iniface = getMangleFieldVal('iniface', true);
let outiface = getMangleFieldVal('outiface', true);
let inifacelist = getMangleFieldVal('inifacelist', true);
let outifacelist = getMangleFieldVal('outifacelist', true);
if (chainLower === 'prerouting' || chainLower === 'input') {
outiface = '';
outifacelist = '';
} else if (chainLower === 'output' || chainLower === 'postrouting') {
iniface = '';
inifacelist = '';
}
if (!chain) { switchMangleRuleTab('general'); showToast('Mangle rule', 'Enter a chain.', 'error'); document.getElementById('mangle-modal-chain').focus(); return; }
if (action === 'set-priority' && chainLower !== 'postrouting') {
switchMangleRuleTab('general');
showToast('Invalid Target for Chain', 'CLASSIFY (set-priority) requires POSTROUTING chain in iptables.', 'error');
return;
}
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: (chainLower === 'output' || chainLower === 'postrouting') ? '' : getMangleFieldVal('srcmac', true),
out_bridge_port: (chainLower === 'input' || chainLower === 'prerouting') ? '' : getMangleFieldVal('outbridgeport', false),
in_bridge_port: (chainLower === 'output' || chainLower === 'postrouting') ? '' : 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');
}
}
// ======================== VXLAN (Virtual Extensible LAN) ========================
async function loadVXLAN(populate = true) {
try {
const [tunnelsRes, vtepsRes] = await Promise.all([
fetch(`${API_BASE}/vpn/vxlan`),
fetch(`${API_BASE}/vpn/vxlan/vteps`)
]);
if (tunnelsRes.ok) {
state.vxlanTunnels = await tunnelsRes.json() || [];
}
if (vtepsRes.ok) {
state.vxlanVteps = await vtepsRes.json() || [];
}
renderVXLANTable();
renderVXLANVtepsTable();
const countBadge = document.getElementById('vxlan-count-badge');
if (countBadge) countBadge.innerText = `${(state.vxlanTunnels || []).length} Tunnels`;
const vtepsCountBadge = document.getElementById('vxlan-vteps-count-badge');
if (vtepsCountBadge) vtepsCountBadge.innerText = `${(state.vxlanVteps || []).length} VTEPs`;
const navBadge = document.getElementById('badge-vxlan-count');
if (navBadge) navBadge.innerText = (state.vxlanTunnels || []).length;
} catch (err) {
console.error('Error loading VXLAN:', err);
}
}
function renderVXLANTable() {
const tbody = document.getElementById('vxlan-table-body');
const empty = document.getElementById('vxlan-empty');
if (!tbody || !empty) return;
tbody.innerHTML = '';
const tunnels = state.vxlanTunnels || [];
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 border-b border-slate-800/60';
let flag = '';
if (t.disabled) {
flag = 'X';
} else if (t.running) {
flag = 'R';
} else {
flag = '!';
}
const vtepCount = (t.vteps || []).length;
const underlayDesc = t.parent_interface || t.local_address ? `${t.parent_interface || '-'}${t.local_address ? ' / ' + t.local_address : ''}` : '(any)';
tr.innerHTML = `
${flag} |
${escapeHtml(t.name)} |
${t.vni} |
${t.port || 4789} |
${escapeHtml(t.ip_address || '') || 'Unassigned'} |
${escapeHtml(underlayDesc)} |
${escapeHtml(t.mac_address || '-')} |
${t.mtu || 1500} |
${vtepCount} VTEP${vtepCount === 1 ? '' : 's'}
|
↓ ${t.rx_human || '0 B'}
↑ ${t.tx_human || '0 B'}
|
${escapeHtml(t.comment || '-')} |
|
`;
tbody.appendChild(tr);
});
}
function renderVXLANVtepsTable() {
const tbody = document.getElementById('vxlan-vteps-table-body');
const empty = document.getElementById('vxlan-vteps-empty');
if (!tbody || !empty) return;
tbody.innerHTML = '';
const vteps = state.vxlanVteps || [];
if (vteps.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
vteps.forEach(v => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition border-b border-slate-800/60';
tr.innerHTML = `
${escapeHtml(v.interface)} |
${escapeHtml(v.remote_ip)} |
${v.port > 0 ? v.port : 'default'} |
${escapeHtml(v.comment || '-')} |
|
`;
tbody.appendChild(tr);
});
}
function openAddVXLANModal() {
document.getElementById('vxlan-modal-title').innerText = 'Add VXLAN Interface';
document.getElementById('vxlan-modal-id').value = '';
document.getElementById('vxlan-modal-name').value = `vxlan${(state.vxlanTunnels || []).length + 100}`;
document.getElementById('vxlan-modal-vni').value = (state.vxlanTunnels || []).length + 100;
document.getElementById('vxlan-modal-port').value = '4789';
document.getElementById('vxlan-modal-ip').value = '';
document.getElementById('vxlan-modal-parent').value = '';
document.getElementById('vxlan-modal-local').value = '';
document.getElementById('vxlan-modal-remote').value = '';
document.getElementById('vxlan-modal-group').value = '';
document.getElementById('vxlan-modal-mac').value = '';
document.getElementById('vxlan-modal-mtu').value = '1500';
document.getElementById('vxlan-modal-comment').value = '';
document.getElementById('vxlan-modal-learning').checked = true;
document.getElementById('vxlan-modal-disabled').checked = false;
document.getElementById('vxlan-modal').classList.remove('hidden');
}
function openEditVXLANModal(id) {
const t = (state.vxlanTunnels || []).find(tun => tun.id === id);
if (!t) return;
document.getElementById('vxlan-modal-title').innerText = `Edit VXLAN Interface (${t.name})`;
document.getElementById('vxlan-modal-id').value = t.id;
document.getElementById('vxlan-modal-name').value = t.name;
document.getElementById('vxlan-modal-vni').value = t.vni;
document.getElementById('vxlan-modal-port').value = t.port || 4789;
document.getElementById('vxlan-modal-ip').value = t.ip_address || '';
document.getElementById('vxlan-modal-parent').value = t.parent_interface || '';
document.getElementById('vxlan-modal-local').value = t.local_address || '';
document.getElementById('vxlan-modal-remote').value = t.remote_address || '';
document.getElementById('vxlan-modal-group').value = t.group || '';
document.getElementById('vxlan-modal-mac').value = t.mac_address || '';
document.getElementById('vxlan-modal-mtu').value = t.mtu || 1500;
document.getElementById('vxlan-modal-comment').value = t.comment || '';
document.getElementById('vxlan-modal-learning').checked = t.learning !== false;
document.getElementById('vxlan-modal-disabled').checked = !!t.disabled;
document.getElementById('vxlan-modal').classList.remove('hidden');
}
function closeVXLANModal() {
document.getElementById('vxlan-modal').classList.add('hidden');
}
async function submitSaveVXLAN(e) {
e.preventDefault();
const id = document.getElementById('vxlan-modal-id').value;
const name = document.getElementById('vxlan-modal-name').value.trim();
const vni = parseInt(document.getElementById('vxlan-modal-vni').value);
const port = parseInt(document.getElementById('vxlan-modal-port').value) || 4789;
const ipAddress = document.getElementById('vxlan-modal-ip').value.trim();
const parentInterface = document.getElementById('vxlan-modal-parent').value.trim();
const local = document.getElementById('vxlan-modal-local').value.trim();
const remote = document.getElementById('vxlan-modal-remote').value.trim();
const group = document.getElementById('vxlan-modal-group').value.trim();
const mac = document.getElementById('vxlan-modal-mac').value.trim();
const mtu = parseInt(document.getElementById('vxlan-modal-mtu').value) || 1500;
const comment = document.getElementById('vxlan-modal-comment').value.trim();
const learning = document.getElementById('vxlan-modal-learning').checked;
const disabled = document.getElementById('vxlan-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/vpn/vxlan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name,
vni,
port,
ip_address: ipAddress,
parent_interface: parentInterface,
local_address: local,
remote_address: remote,
group,
mac_address: mac,
mtu,
learning,
comment,
disabled
})
});
if (res.ok) {
showToast('VXLAN Saved', `Interface ${name} configured successfully`, 'success');
closeVXLANModal();
loadVXLAN(false);
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleVXLANState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
showToast(disabled ? 'Disabled' : 'Enabled', 'VXLAN interface state updated', 'info');
loadVXLAN(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteVXLAN(id) {
if (!confirm('Are you sure you want to delete this VXLAN interface?')) return;
try {
const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'VXLAN interface removed', 'success');
loadVXLAN(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function openAddVXLANVtepModal(defaultIface = '') {
const sel = document.getElementById('vxlan-vtep-modal-interface');
if (sel) {
sel.innerHTML = '';
const tunnels = state.vxlanTunnels || [];
tunnels.forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.innerText = `${t.name} (VNI ${t.vni})`;
if (defaultIface && t.name === defaultIface) {
opt.selected = true;
}
sel.appendChild(opt);
});
}
document.getElementById('vxlan-vtep-modal-remote').value = '';
document.getElementById('vxlan-vtep-modal-port').value = '';
document.getElementById('vxlan-vtep-modal-comment').value = '';
document.getElementById('vxlan-vtep-modal').classList.remove('hidden');
}
function closeVXLANVtepModal() {
document.getElementById('vxlan-vtep-modal').classList.add('hidden');
}
async function submitSaveVXLANVtep(e) {
e.preventDefault();
const iface = document.getElementById('vxlan-vtep-modal-interface').value;
const remote = document.getElementById('vxlan-vtep-modal-remote').value.trim();
const port = parseInt(document.getElementById('vxlan-vtep-modal-port').value) || 0;
const comment = document.getElementById('vxlan-vtep-modal-comment').value.trim();
try {
const res = await fetch(`${API_BASE}/vpn/vxlan/vteps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
remote_ip: remote,
port,
comment
})
});
if (res.ok) {
showToast('VTEP Added', `Peer ${remote} added to ${iface}`, 'success');
closeVXLANVtepModal();
loadVXLAN(false);
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteVXLANVtep(iface, vtepIdOrIp) {
if (!confirm(`Are you sure you want to remove VTEP ${vtepIdOrIp} from ${iface}?`)) return;
try {
const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(iface)}/vteps/${encodeURIComponent(vtepIdOrIp)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('Deleted', 'Remote VTEP removed', 'success');
loadVXLAN(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== AeroTUN (Custom Encrypted VPN with Adaptive LZ4) ========================
const aeroRateSamples = new Map();
const aeroLiveRates = new Map();
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 : [];
const now = Date.now();
for (const t of state.aerotunTunnels) {
const old = aeroRateSamples.get(t.id), seconds = old ? (now-old.time)/1000 : 0;
if (seconds >= 0.5 && seconds <= 60 && t.rx_bytes >= old.rx && t.tx_bytes >= old.tx && t.packets_rx >= old.prx && t.packets_tx >= old.ptx) {
aeroLiveRates.set(t.id,{rx:(t.rx_bytes-old.rx)*8/seconds/1e6,tx:(t.tx_bytes-old.tx)*8/seconds/1e6,prx:(t.packets_rx-old.prx)/seconds,ptx:(t.packets_tx-old.ptx)/seconds});
} else if (!old || seconds > 60 || t.rx_bytes < old.rx || t.tx_bytes < old.tx) aeroLiveRates.delete(t.id);
if (!old || seconds >= 0.5) aeroRateSamples.set(t.id,{time:now,rx:t.rx_bytes||0,tx:t.tx_bytes||0,prx:t.packets_rx||0,ptx:t.packets_tx||0});
}
}
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();
if (state.aeroHealthSelected) await refreshAeroTUNHealth();
} 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);
const rate = aeroLiveRates.get(t.id);
const id = encodeURIComponent(String(t.id || '')).replace(/'/g, '%27');
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}
${rate ? ` ↓ ${rate.rx.toFixed(2)} / ↑ ${rate.tx.toFixed(2)} Mbit/s ` : ''}
|
↓ ${wireRx}
↑ ${wireTx}
|
${escapeHtml(t.comment || '-')} |
${t.running
? ``
: ``
}
|
`;
tbody.appendChild(tr);
});
}
async function openAeroTUNHealth(encodedID) {
state.aeroHealthSelected = decodeURIComponent(encodedID);
const tunnel=(state.aerotunTunnels||[]).find(x=>x.id===state.aeroHealthSelected);
let underlay='';try {underlay=new URL(`http://${tunnel?.remote_endpoint||tunnel?.active_peer_endpoint||''}`).hostname.replace(/^\[|\]$/g,'')} catch {}
document.getElementById('aerotun-compare-host').value=underlay;
document.getElementById('aerotun-compare-result').textContent='';
document.getElementById('aerotun-health-result').textContent = '';
document.getElementById('aerotun-health-panel').classList.remove('hidden');
await refreshAeroTUNHealth();
document.getElementById('aerotun-health-panel').scrollIntoView({behavior:'smooth',block:'nearest'});
}
function closeAeroTUNHealth() { aeroCompareCancel=true; state.aeroHealthSelected = ''; document.getElementById('aerotun-health-panel').classList.add('hidden'); }
async function refreshAeroTUNHealth() {
const id=state.aeroHealthSelected,t=(state.aerotunTunnels||[]).find(x=>x.id===id);if(!id)return;
if(!t){document.getElementById('aerotun-health-status').textContent='Tunnel no longer exists.';return}
document.getElementById('aerotun-health-title').textContent=`Health: ${t.name}`;
try {
const response=await fetch(`${API_BASE}/vpn/aerotun/health`);if(!response.ok)throw new Error(await responseError(response));const data=await response.json();if(state.aeroHealthSelected!==id)return;
const current=(data.states||[]).find(x=>x.tunnel_id===id);const status=current?.status||'unknown';const since=current?.since&&!current.since.startsWith('0001')?new Date(current.since).toLocaleString():'—';const rate=aeroLiveRates.get(id);
const d=t.drop_breakdown;
document.getElementById('aerotun-health-status').textContent=`Status: ${status} since ${since} · Backend: ${t.backend||'—'} · MTU: ${t.mtu||'—'} / configured ${t.configured_mtu||(!t.running?t.mtu:'—')||'—'} · Last authenticated packet: ${t.last_handshake||'never'} · Drops: ${t.dropped_packets||0}${d?` (outgoing invalid ${d.invalid_outgoing}, session ${d.missing_session}, send ${d.send_failure}, incoming invalid ${d.invalid_incoming}, source policy ${d.source_policy}, interface ${d.interface_write})`:''} · Authentication failures: ${t.auth_failures||0} · Replay drops: ${t.replay_drops||0}${Number.isFinite(data.service_cpu_percent)?` · Service CPU (all features): ${data.service_cpu_percent.toFixed(1)}% of one core`:''}${rate?` · RX/TX: ${rate.rx.toFixed(2)} / ${rate.tx.toFixed(2)} Mbit/s, ${rate.prx.toFixed(0)} / ${rate.ptx.toFixed(0)} packets/s`:''}${t.last_error?` · Last error: ${t.last_error}`:''}`;
const events=(data.events||[]).filter(e=>e.tunnel_id===id).slice(0,100),node=document.getElementById('aerotun-health-history');node.replaceChildren();
if(!events.length){node.textContent='No events recorded yet.';return}
for(const e of events){const line=document.createElement('div');line.textContent=`${new Date(e.time).toLocaleString()} · ${e.type==='check'?'Check':(e.previous?`${e.previous} → ${e.status}${e.previous_duration_seconds?` (after ${Math.round(e.previous_duration_seconds)} s)`:''}`:e.status)}${e.detail?` · ${e.detail}`:''}`;node.appendChild(line)}
} catch(e){document.getElementById('aerotun-health-history').textContent=`Health history unavailable: ${e.message}`}
}
async function runAeroTUNHealthCheck() {
const id=state.aeroHealthSelected;if(!id)return;const button=document.getElementById('aerotun-health-check-btn'),output=document.getElementById('aerotun-health-result');button.disabled=true;output.textContent='Checking…';
try {const response=await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}/health-check`,{method:'POST'});if(!response.ok)throw new Error(await responseError(response));const r=await response.json();if(state.aeroHealthSelected!==id)return;output.textContent=`Peer echo: ${r.reachability}${r.rtt_ms!=null?` · RTT ${r.rtt_upper_bound?'<':''}${r.rtt_ms} ms`:''} · ${r.mtu}-byte overlay probe: ${r.mtu_probe}. ${r.explanation}`;await refreshAeroTUNHealth()}
catch(e){if(state.aeroHealthSelected===id)output.textContent=`Health check failed: ${e.message}`}
finally {button.disabled=false}
}
let aeroCompareRunning=false, aeroCompareCancel=false;
function stopAeroTUNComparison() { aeroCompareCancel=true;document.getElementById('aerotun-compare-result').textContent+='\nStopping after the current test.'; }
async function runAeroTUNComparison() {
if(aeroCompareRunning)return;
const id=state.aeroHealthSelected,t=(state.aerotunTunnels||[]).find(x=>x.id===id),direct=document.getElementById('aerotun-compare-host').value.trim(),port=Number(document.getElementById('aerotun-compare-port').value),peer=(t?.peer_address||'').split('/')[0].trim();
const output=document.getElementById('aerotun-compare-result'),button=document.getElementById('aerotun-compare-btn'),stop=document.getElementById('aerotun-compare-stop');
if(!t?.connected||!peer||!direct||!Number.isInteger(port)||port<1||port>65535||peer===direct){output.textContent='A connected tunnel, peer IP, different direct server address and valid port are required.';return}
if(peer.includes(':')||direct.includes(':')){output.textContent='This comparison currently supports IPv4 addresses or IPv4-resolvable hostnames.';return}
aeroCompareRunning=true;aeroCompareCancel=false;button.disabled=true;stop.classList.remove('hidden');output.textContent='Starting. The direct address uses the system routing table.';
const get=async(path,body)=>{const r=await fetch(`${API_BASE}${path}`,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{});if(!r.ok)throw new Error(await responseError(r));return r.json()};
const results=[];
try {
for(const [path,target,direction] of [['Tunnel',peer,'tx'],['Tunnel',peer,'rx'],['Direct',direct,'tx'],['Direct',direct,'rx']]) {
if(aeroCompareCancel)break;
if((await get('/tools/bwtest/status')).running)throw new Error('Another bandwidth test is running; comparison stopped.');
output.textContent+=`\n${path} ${direction.toUpperCase()}: testing…`;
const started=await get('/tools/bwtest/start',{target,port,protocol:'tcp',direction,bandwidth_limit:0,packet_size:1200,streams:2,duration:8}),session=started.session_id;let status,complete=false;
for(let i=0;i<30;i++){await new Promise(resolve=>setTimeout(resolve,1000));status=await get('/tools/bwtest/status');if(status.session_id!==session)throw new Error('Bandwidth test session changed; comparison stopped.');if(!status.running){complete=true;break}}
if(!complete)throw new Error('Timed out waiting for the bandwidth test.');if(status.error)throw new Error(`${path} ${direction.toUpperCase()}: ${status.error}`);
const value=status.summary?.[direction==='tx'?'avg_tx_mbps':'avg_rx_mbps'];if(!Number.isFinite(value)||value<=0)throw new Error(`${path} ${direction.toUpperCase()}: no delivered data.`);
results.push(`${path} ${direction.toUpperCase()}: ${value.toFixed(2)} Mbit/s`);output.textContent='Results:\n'+results.join('\n');
}
if(aeroCompareCancel)output.textContent+='\nComparison stopped.';
else output.textContent+='\nDirect results are meaningful only if both addresses reach the same server over different routes.';
} catch(e){output.textContent+=`\nComparison incomplete: ${e.message}`}
finally {aeroCompareRunning=false;button.disabled=false;stop.classList.add('hidden')}
}
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.configured_mtu || 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 || 'debtik-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,
commLists, asLists,
ripInsts, ripIfaces, ripNbrsLive, ripRoutes,
bfdInst, bfdIfaces, bfdPeers, bfdSessions,
babelInsts, babelIfaces, babelLiveIfaces, babelNbrs, babelRoutes,
radvInsts, radvIfaces
] = 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/community-lists`),
fetchSafeJson(`${API_BASE}/routing/filters/as-lists`),
fetchSafeJson(`${API_BASE}/routing/rip/instances`),
fetchSafeJson(`${API_BASE}/routing/rip/interfaces`),
fetchSafeJson(`${API_BASE}/routing/rip/neighbors-live`),
fetchSafeJson(`${API_BASE}/routing/rip/routes`),
fetchSafeJson(`${API_BASE}/routing/bfd/instance`),
fetchSafeJson(`${API_BASE}/routing/bfd/interfaces`),
fetchSafeJson(`${API_BASE}/routing/bfd/peers`),
fetchSafeJson(`${API_BASE}/routing/bfd/sessions`),
fetchSafeJson(`${API_BASE}/routing/babel/instances`),
fetchSafeJson(`${API_BASE}/routing/babel/interfaces`),
fetchSafeJson(`${API_BASE}/routing/babel/interfaces-live`),
fetchSafeJson(`${API_BASE}/routing/babel/neighbors`),
fetchSafeJson(`${API_BASE}/routing/babel/routes`),
fetchSafeJson(`${API_BASE}/routing/radv/instances`),
fetchSafeJson(`${API_BASE}/routing/radv/interfaces`)
]);
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 (commLists) state.routingCommunityLists = commLists;
if (asLists) state.routingASLists = asLists;
if (ripInsts) state.ripInstances = ripInsts;
if (ripIfaces) state.ripInterfaces = ripIfaces;
if (ripNbrsLive) state.ripNeighborsLive = ripNbrsLive;
if (ripRoutes) state.ripRoutes = ripRoutes;
if (bfdInst) state.bfdInstance = bfdInst;
if (bfdIfaces) state.bfdInterfaces = bfdIfaces;
if (bfdPeers) state.bfdNeighbors = bfdPeers;
if (bfdSessions) state.bfdSessions = bfdSessions;
if (babelInsts) state.babelInstances = babelInsts;
if (babelIfaces) state.babelInterfaces = babelIfaces;
if (babelLiveIfaces) state.babelInterfacesLive = babelLiveIfaces;
if (babelNbrs) state.babelNeighbors = babelNbrs;
if (babelRoutes) state.babelRoutes = babelRoutes;
if (radvInsts) state.radvInstances = radvInsts;
if (radvIfaces) state.radvInterfaces = radvIfaces;
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 { renderRipInstances(); } catch(e) { console.error('renderRipInstances error:', e); }
try { renderRipInterfaces(); } catch(e) { console.error('renderRipInterfaces error:', e); }
try { renderRipNeighbors(); } catch(e) { console.error('renderRipNeighbors error:', e); }
try { renderRipRoutes(); } catch(e) { console.error('renderRipRoutes error:', e); }
try { renderBfdConfig(); } catch(e) { console.error('renderBfdConfig error:', e); }
try { renderBfdInterfaces(); } catch(e) { console.error('renderBfdInterfaces error:', e); }
try { renderBfdPeers(); } catch(e) { console.error('renderBfdPeers error:', e); }
try { renderBfdSessions(); } catch(e) { console.error('renderBfdSessions error:', e); }
try { renderBabelInstances(); } catch(e) { console.error('renderBabelInstances error:', e); }
try { renderBabelInterfaces(); } catch(e) { console.error('renderBabelInterfaces error:', e); }
try { renderBabelLiveInterfaces(); } catch(e) { console.error('renderBabelLiveInterfaces error:', e); }
try { renderBabelNeighbors(); } catch(e) { console.error('renderBabelNeighbors error:', e); }
try { renderBabelRoutes(); } catch(e) { console.error('renderBabelRoutes error:', e); }
try { renderRAdvInstances(); } catch(e) { console.error('renderRAdvInstances error:', e); }
try { renderRAdvInterfaces(); } catch(e) { console.error('renderRAdvInterfaces 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'}
|
${n.disabled
? '-'
: 'blackhole (dist 255)'}
|
${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'
: (inst.running
? 'R'
: 'I');
const redist = [];
if (inst.redistribute_connected) redist.push('conn');
if (inst.redistribute_static) redist.push('static');
if (inst.redistribute_bgp) redist.push('bgp');
if (inst.redistribute_default || (inst.originate_default && inst.originate_default !== 'never')) {
redist.push('default');
}
const origDef = inst.originate_default || (inst.redistribute_default ? 'if-installed' : 'never');
const origBadge = origDef === 'always'
? 'always'
: (origDef === 'if-installed'
? 'if-installed'
: 'never');
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')} |
${redist.join(', ') || '-'} |
${origBadge} |
`;
}).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) {
// Normalize legacy tab names
if (tab === 'extra' || tab === 'filter') tab = 'filters';
if (tab === 'attributes') tab = 'networks';
const tabs = ['general', 'filters', 'networks'];
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 '30';
return String(secs);
}
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 populateBgpLocalAddrDatalist() {
const dlBgp = document.getElementById('bgp-peer-local-addr-datalist');
if (!dlBgp) return;
const addrs = [];
(state.interfaces || []).forEach(iface => {
if (iface.ip) addrs.push(iface.ip.split('/')[0]);
(iface.addresses || []).forEach(a => addrs.push(a.split('/')[0]));
});
dlBgp.innerHTML = Array.from(new Set(addrs.filter(Boolean))).map(ip => ``).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 dl = document.getElementById('ospf-tmpl-iface-datalist');
if (dl) {
dl.innerHTML = '`).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;
await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
loadDynamicRouting();
}
async function disableSelectedOspfStaticNbr() {
const id = state.selectedOspfRow?.['static-nbrs'];
if (!id) return;
await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
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, vxlanRes, 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}/vpn/vxlan`),
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 (vxlanRes && vxlanRes.ok) state.vxlanTunnels = await vxlanRes.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);
// 4. VXLAN Tunnels (MikroTik VXLAN bridging!)
const grpVxlan = document.createElement('optgroup');
grpVxlan.label = 'VXLAN Tunnels (Layer 2 Overlay)';
(state.vxlanTunnels || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.innerText = `${t.name} (VNI: ${t.vni}, Port: ${t.port})`;
if (t.name === currentVal) opt.selected = true;
grpVxlan.appendChild(opt);
});
if (grpVxlan.children.length > 0) sel.appendChild(grpVxlan);
}
// -------------------------------------------------------------
// 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', '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') 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 === 'community-lists') renderRoutingFilterCommunityLists();
else if (tab === 'as-lists') renderRoutingFilterASLists();
}
function openRoutingFilterNewModal() {
const tab = state.activeRoutingFilterTab || 'rules';
if (tab === 'rules') openAddRoutingFilterModal();
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 === '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 === '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 === '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 === '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 += '';
// Group 2: VLAN Interfaces
if (allVlans.length > 0) {
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 btnBak = document.getElementById('btn-settings-subtab-backup');
const tabDns = document.getElementById('settings-subtab-dns');
const tabSys = document.getElementById('settings-subtab-system');
const tabBak = document.getElementById('settings-subtab-backup');
const activeClass = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5 cursor-pointer';
const inactiveClass = '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 cursor-pointer';
if (btnDns) btnDns.className = inactiveClass;
if (btnSys) btnSys.className = inactiveClass;
if (btnBak) btnBak.className = inactiveClass;
tabDns?.classList.add('hidden');
tabSys?.classList.add('hidden');
tabBak?.classList.add('hidden');
if (sub === 'dns') {
if (btnDns) btnDns.className = activeClass;
tabDns?.classList.remove('hidden');
loadDNSSettings();
} else if (sub === 'system') {
if (btnSys) btnSys.className = activeClass;
tabSys?.classList.remove('hidden');
loadSystemSettings();
} else if (sub === 'backup') {
if (btnBak) btnBak.className = activeClass;
tabBak?.classList.remove('hidden');
loadBackupList();
}
}
async function loadSettingsData(showLoading = false) {
if (state.settingsSubTab === 'system') {
await loadSystemSettings();
} else if (state.settingsSubTab === 'backup') {
await loadBackupList();
} 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');
}
}
// =============================================================================
// BACKUP & RESTORE MANAGER
// =============================================================================
state.selectedBackupFile = null;
state.inspectedBackupManifest = null;
state.pendingRestoreTarget = null;
state.backupsList = [];
async function exportBackupDownload() {
const btn = document.getElementById('btn-export-backup');
const commentInput = document.getElementById('backup-export-comment');
const comment = (commentInput?.value || '').trim();
const originalHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = ` Generating Backup...`;
}
try {
const res = await fetch(`${API_BASE}/system/backup/export?comment=${encodeURIComponent(comment)}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
showToast('Export Failed', err.message || 'Failed to generate backup archive', 'error');
return;
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
let filename = 'debtik-backup.zip';
const match = disposition.match(/filename="?([^";]+)"?/);
if (match && match[1]) {
filename = match[1];
}
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
a.remove();
}, 1000);
showToast('Backup Created', `Full configuration archive saved as ${filename}`, 'success');
} catch (err) {
showToast('Export Error', err.message || 'Network error during download', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
}
async function onBackupFileSelected(e) {
const file = e.target.files?.[0];
if (!file) return;
const label = document.getElementById('backup-file-label');
if (label) {
label.innerText = `Selected: ${file.name} (${formatBytes(file.size)})`;
}
await inspectBackupFile(file);
}
async function inspectBackupFile(file) {
const preview = document.getElementById('backup-inspect-preview');
const restoreBtn = document.getElementById('btn-restore-backup');
const formData = new FormData();
formData.append('backup', file);
try {
const res = await fetch(`${API_BASE}/system/backup/inspect`, {
method: 'POST',
body: formData
});
const data = await res.json();
if (!res.ok || !data.success || !data.manifest) {
if (preview) preview.classList.add('hidden');
if (restoreBtn) restoreBtn.disabled = true;
showToast('Validation Error', data.message || 'Invalid or corrupted DebTik backup archive', 'error');
return;
}
const mf = data.manifest;
state.selectedBackupFile = file;
state.inspectedBackupManifest = mf;
const vEl = document.getElementById('backup-inspect-version');
const hEl = document.getElementById('backup-inspect-hostname');
const fEl = document.getElementById('backup-inspect-files');
const dEl = document.getElementById('backup-inspect-date');
const cEl = document.getElementById('backup-inspect-comment');
if (vEl) vEl.innerText = `DebTik v${escapeHtml(mf.debtik_version || '1.0')}`;
if (hEl) hEl.innerText = escapeHtml(mf.hostname || 'DebTik');
if (fEl) fEl.innerText = `${mf.files?.length || 0} items`;
if (dEl) dEl.innerText = mf.created_at ? new Date(mf.created_at).toLocaleString() : 'N/A';
if (cEl) cEl.innerText = mf.comment ? `"${escapeHtml(mf.comment)}"` : '';
if (preview) preview.classList.remove('hidden');
if (restoreBtn) restoreBtn.disabled = false;
} catch (err) {
if (preview) preview.classList.add('hidden');
if (restoreBtn) restoreBtn.disabled = true;
showToast('Inspection Failed', err.message || 'Failed to inspect backup file', 'error');
}
}
function confirmRestoreBackup(localFilename = null) {
const modal = document.getElementById('modal-restore-confirm');
const details = document.getElementById('modal-restore-details');
const progress = document.getElementById('modal-restore-progress');
const cancelBtn = document.getElementById('btn-cancel-restore');
const execBtn = document.getElementById('btn-execute-restore');
if (progress) progress.classList.add('hidden');
if (cancelBtn) cancelBtn.disabled = false;
if (execBtn) {
execBtn.disabled = false;
execBtn.innerText = 'Yes, Restore & Reload';
}
if (localFilename) {
state.pendingRestoreTarget = { type: 'local', filename: localFilename };
if (details) {
details.innerHTML = `Source: Local Safety Backup
File: ${escapeHtml(localFilename)}
`;
}
} else {
if (!state.selectedBackupFile || !state.inspectedBackupManifest) {
showToast('Error', 'Please select a valid backup archive first', 'error');
return;
}
state.pendingRestoreTarget = { type: 'file', file: state.selectedBackupFile };
const mf = state.inspectedBackupManifest;
if (details) {
details.innerHTML = `Archive: ${escapeHtml(state.selectedBackupFile.name)} (${formatBytes(state.selectedBackupFile.size)})
Origin Hostname: ${escapeHtml(mf.hostname)}
Created: ${mf.created_at ? new Date(mf.created_at).toLocaleString() : 'N/A'}
Files: ${mf.files?.length || 0} configuration files
`;
}
}
if (modal) modal.classList.remove('hidden');
}
function closeRestoreModal() {
const modal = document.getElementById('modal-restore-confirm');
if (modal) modal.classList.add('hidden');
}
async function executeRestoreBackup() {
const target = state.pendingRestoreTarget;
if (!target) return;
const progress = document.getElementById('modal-restore-progress');
const progressText = document.getElementById('modal-restore-progress-text');
const cancelBtn = document.getElementById('btn-cancel-restore');
const execBtn = document.getElementById('btn-execute-restore');
if (cancelBtn) cancelBtn.disabled = true;
if (execBtn) execBtn.disabled = true;
if (progress) progress.classList.remove('hidden');
if (progressText) progressText.innerText = 'Restoring configuration files into appliance...';
try {
let res;
if (target.type === 'file') {
const formData = new FormData();
formData.append('backup', target.file);
res = await fetch(`${API_BASE}/system/backup/restore`, {
method: 'POST',
body: formData
});
} else {
res = await fetch(`${API_BASE}/system/backups/${encodeURIComponent(target.filename)}/restore`, {
method: 'POST'
});
}
const data = await res.json();
if (!res.ok || !data.success) {
showToast('Restore Failed', data.message || 'Error occurred while restoring configuration', 'error');
if (cancelBtn) cancelBtn.disabled = false;
if (execBtn) execBtn.disabled = false;
if (progress) progress.classList.add('hidden');
return;
}
if (progressText) {
progressText.innerText = 'Configuration applied! DebTik appliance is reloading services, reconnecting...';
}
pollAfterRestoreReconnect();
} catch (err) {
showToast('Restore Error', err.message || 'Network error during restore', 'error');
if (cancelBtn) cancelBtn.disabled = false;
if (execBtn) execBtn.disabled = false;
if (progress) progress.classList.add('hidden');
}
}
function pollAfterRestoreReconnect() {
let attempts = 0;
const maxAttempts = 25;
const timer = setInterval(async () => {
attempts++;
try {
const res = await fetch(`${API_BASE}/system/status`, { cache: 'no-store' });
if (res.ok) {
clearInterval(timer);
closeRestoreModal();
showToast('Restoration Complete', 'DebTik configuration restored and appliance is back online!', 'success');
setTimeout(() => {
window.location.reload();
}, 1200);
}
} catch (_) {
// Still restarting, retry
}
if (attempts >= maxAttempts) {
clearInterval(timer);
closeRestoreModal();
showToast('Reload Notice', 'Restore applied. Please refresh your browser to re-authenticate.', 'info');
}
}, 1500);
}
async function loadBackupList() {
const tbody = document.getElementById('backups-table-tbody');
const empty = document.getElementById('backups-empty-state');
if (!tbody) return;
try {
const res = await fetch(`${API_BASE}/system/backups`);
if (!res.ok) return;
const list = await res.json();
state.backupsList = list || [];
renderBackupsTable(state.backupsList);
} catch (err) {
console.error('Failed to load backup list:', err);
}
}
function renderBackupsTable(backups) {
const tbody = document.getElementById('backups-table-tbody');
const empty = document.getElementById('backups-empty-state');
if (!tbody) return;
if (!backups || backups.length === 0) {
tbody.innerHTML = '';
if (empty) empty.classList.remove('hidden');
return;
}
if (empty) empty.classList.add('hidden');
tbody.innerHTML = backups.map(bk => {
const isAuto = bk.filename && bk.filename.startsWith('pre-restore-');
const badge = isAuto
? `PRE-RESTORE`
: `MANUAL`;
const dateStr = bk.mod_time ? new Date(bk.mod_time).toLocaleString() : 'N/A';
return `
|
${badge}
${escapeHtml(bk.filename)}
|
${escapeHtml(bk.debtik_version || '1.0')} |
${escapeHtml(bk.hostname || 'DebTik')} |
${dateStr} |
${formatBytes(bk.size)} |
${escapeHtml(bk.comment || '—')} |
|
`;
}).join('');
}
async function downloadLocalBackup(filename) {
try {
const res = await fetch(`${API_BASE}/system/backups/${encodeURIComponent(filename)}`);
if (!res.ok) {
showToast('Download Error', 'Failed to retrieve backup file', 'error');
return;
}
const blob = await res.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
a.remove();
}, 1000);
} catch (err) {
showToast('Download 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} |
|
${statusBadge} |
${s.comment || '-'} |
|
`;
}).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;
}
// ==========================================
// REVERSE PROXY & APPLICATION GATEWAY
// ==========================================
state.proxyRules = [];
state.proxyConfig = null;
async function loadReverseProxy(manual = false) {
try {
const [cfgRes, rulesRes, certsRes] = await Promise.all([
fetch(`${API_BASE}/proxy/config`),
fetch(`${API_BASE}/proxy/rules`),
fetch(`${API_BASE}/certificates`).catch(() => null)
]);
if (!cfgRes.ok) throw new Error('Failed to load proxy config');
if (!rulesRes.ok) throw new Error('Failed to load proxy rules');
state.proxyConfig = await cfgRes.json();
state.proxyRules = await rulesRes.json() || [];
if (certsRes && certsRes.ok) {
state.certificates = await certsRes.json() || [];
}
renderProxyRules(state.proxyRules);
updateProxyMetrics();
if (manual) showToast('Reverse Proxy', 'Proxy configuration and rules reloaded', 'success');
} catch (err) {
console.error('loadReverseProxy error:', err);
if (manual) showToast('Error', err.message, 'error');
}
}
function updateProxyMetrics() {
const badge = document.getElementById('badge-proxy-count');
if (badge) badge.textContent = (state.proxyRules || []).length;
const metricRules = document.getElementById('proxy-metric-rules');
if (metricRules) metricRules.textContent = (state.proxyRules || []).length;
const cfg = state.proxyConfig;
const statusEl = document.getElementById('proxy-metric-status');
const portsEl = document.getElementById('proxy-metric-ports');
if (cfg) {
if (statusEl) {
if (cfg.enabled) {
statusEl.innerHTML = 'Active';
} else {
statusEl.innerHTML = 'Disabled';
}
}
if (portsEl) {
portsEl.textContent = `HTTP: ${cfg.http_port || 80} | HTTPS: ${cfg.https_port || 443}`;
}
}
let totalRequests = 0;
const certSet = new Set();
(state.proxyRules || []).forEach(r => {
totalRequests += (r.stats && r.stats.requests) || 0;
if (r.enabled && r.certificate) certSet.add(r.certificate.toLowerCase());
});
const metricRequests = document.getElementById('proxy-metric-requests');
if (metricRequests) metricRequests.textContent = totalRequests.toLocaleString();
const metricCerts = document.getElementById('proxy-metric-certs');
if (metricCerts) metricCerts.textContent = certSet.size;
}
function renderProxyRules(rules) {
const tbody = document.getElementById('proxy-rules-table-body');
if (!tbody) return;
if (!rules || rules.length === 0) {
tbody.innerHTML = '| No reverse proxy rules configured yet. Click "Add Proxy Rule" to begin. |
';
return;
}
tbody.innerHTML = rules.map(r => {
const isEnabled = r.enabled !== false;
const domainsStr = (r.domains && r.domains.length > 0) ? r.domains.map(d => `${escapeHtml(d)}`).join(' ') : '* (any)';
const pathBadge = `${escapeHtml(r.path || '/')}`;
let protoBadge = 'HTTP/S';
if (r.protocol === 'https') protoBadge = 'HTTPS';
else if (r.protocol === 'http') protoBadge = 'HTTP';
const certBadge = r.certificate ? `🔒 ${escapeHtml(r.certificate)}` : '';
const stripBadge = r.strip_path ? 'Yes' : 'No';
const requests = (r.stats && r.stats.requests) ? r.stats.requests.toLocaleString() : '0';
return `
|
|
${escapeHtml(r.name || 'Unnamed Rule')}
${r.comment ? `${escapeHtml(r.comment)} ` : ''}
|
${domainsStr}
|
${pathBadge}
|
→ ${escapeHtml(r.target_url || '')}
|
${protoBadge}${certBadge}
|
${stripBadge}
|
${requests}
|
|
`;
}).join('');
}
function populateProxyCertDropdown(selectedVal = '') {
const select = document.getElementById('proxy-rule-cert');
if (!select) return;
const certs = state.certificates || [];
select.innerHTML = '';
certs.filter(c => !c.is_ca).forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
const cn = c.common_name ? ` (${c.common_name})` : '';
opt.textContent = `${c.name}${cn}`;
if (selectedVal && selectedVal.toLowerCase() === c.name.toLowerCase()) {
opt.selected = true;
}
select.appendChild(opt);
});
}
function populateDefaultProxyCertDropdown(selectedVal = '') {
const select = document.getElementById('proxy-cfg-default-cert');
if (!select) return;
const certs = state.certificates || [];
select.innerHTML = '';
certs.filter(c => !c.is_ca).forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
const cn = c.common_name ? ` (${c.common_name})` : '';
opt.textContent = `${c.name}${cn}`;
if (selectedVal && selectedVal.toLowerCase() === c.name.toLowerCase()) {
opt.selected = true;
}
select.appendChild(opt);
});
}
function onProxyProtoChange() {
const proto = document.getElementById('proxy-rule-proto')?.value;
const certSelect = document.getElementById('proxy-rule-cert');
const redirectCheck = document.getElementById('proxy-rule-http-to-https');
if (proto === 'http') {
if (certSelect) certSelect.disabled = true;
if (redirectCheck) redirectCheck.disabled = true;
} else {
if (certSelect) certSelect.disabled = false;
if (redirectCheck) redirectCheck.disabled = false;
}
}
function openAddProxyRuleModal() {
const modal = document.getElementById('modal-proxy-rule');
if (!modal) return;
document.getElementById('proxy-rule-modal-title').innerHTML = `
Add Reverse Proxy Rule
`;
document.getElementById('proxy-rule-id').value = '';
document.getElementById('proxy-rule-name').value = '';
document.getElementById('proxy-rule-domains').value = '';
document.getElementById('proxy-rule-path').value = '/';
document.getElementById('proxy-rule-target').value = '';
document.getElementById('proxy-rule-proto').value = 'both';
document.getElementById('proxy-rule-strip-path').checked = false;
document.getElementById('proxy-rule-http-to-https').checked = false;
document.getElementById('proxy-rule-preserve-host').checked = false;
document.getElementById('proxy-rule-insecure-tls').checked = false;
document.getElementById('proxy-rule-enabled').checked = true;
document.getElementById('proxy-rule-comment').value = '';
populateProxyCertDropdown('');
onProxyProtoChange();
modal.classList.remove('hidden');
}
function openEditProxyRuleModal(id) {
const modal = document.getElementById('modal-proxy-rule');
if (!modal) return;
const rule = (state.proxyRules || []).find(r => r.id === id);
if (!rule) {
showToast('Error', 'Rule not found', 'error');
return;
}
document.getElementById('proxy-rule-modal-title').innerHTML = `
Edit Reverse Proxy Rule
`;
document.getElementById('proxy-rule-id').value = rule.id || '';
document.getElementById('proxy-rule-name').value = rule.name || '';
document.getElementById('proxy-rule-domains').value = (rule.domains || []).join(', ');
document.getElementById('proxy-rule-path').value = rule.path || '/';
document.getElementById('proxy-rule-target').value = rule.target_url || '';
document.getElementById('proxy-rule-proto').value = rule.protocol || 'both';
document.getElementById('proxy-rule-strip-path').checked = !!rule.strip_path;
document.getElementById('proxy-rule-http-to-https').checked = !!rule.http_to_https;
document.getElementById('proxy-rule-preserve-host').checked = !!rule.preserve_host;
document.getElementById('proxy-rule-insecure-tls').checked = !!rule.insecure_tls;
document.getElementById('proxy-rule-enabled').checked = rule.enabled !== false;
document.getElementById('proxy-rule-comment').value = rule.comment || '';
populateProxyCertDropdown(rule.certificate || '');
onProxyProtoChange();
modal.classList.remove('hidden');
}
function closeProxyRuleModal() {
const modal = document.getElementById('modal-proxy-rule');
if (modal) modal.classList.add('hidden');
}
async function saveProxyRule(event) {
event.preventDefault();
const id = document.getElementById('proxy-rule-id').value;
const name = document.getElementById('proxy-rule-name').value.trim();
const rawDomains = document.getElementById('proxy-rule-domains').value.trim();
const pathVal = document.getElementById('proxy-rule-path').value.trim() || '/';
const target = document.getElementById('proxy-rule-target').value.trim();
const proto = document.getElementById('proxy-rule-proto').value;
const cert = document.getElementById('proxy-rule-cert').value;
const stripPath = document.getElementById('proxy-rule-strip-path').checked;
const httpToHttps = document.getElementById('proxy-rule-http-to-https').checked;
const preserveHost = document.getElementById('proxy-rule-preserve-host').checked;
const insecureTLS = document.getElementById('proxy-rule-insecure-tls').checked;
const enabled = document.getElementById('proxy-rule-enabled').checked;
const comment = document.getElementById('proxy-rule-comment').value.trim();
let domains = [];
if (rawDomains && rawDomains !== '*') {
domains = rawDomains.split(',').map(d => d.trim()).filter(d => d.length > 0);
}
const payload = {
id: id || undefined,
name,
domains,
path: pathVal,
target_url: target,
protocol: proto,
certificate: cert,
strip_path: stripPath,
http_to_https: httpToHttps,
preserve_host: preserveHost,
insecure_tls: insecureTLS,
enabled,
comment
};
try {
const url = id ? `${API_BASE}/proxy/rules/${encodeURIComponent(id)}` : `${API_BASE}/proxy/rules`;
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || 'Failed to save proxy rule');
}
closeProxyRuleModal();
showToast('Success', `Proxy rule "${name}" saved successfully`, 'success');
await loadReverseProxy(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteProxyRule(id) {
const rule = (state.proxyRules || []).find(r => r.id === id);
const name = rule ? rule.name : id;
if (!confirm(`Are you sure you want to delete proxy rule "${name}"?`)) {
return;
}
try {
const res = await fetch(`${API_BASE}/proxy/rules/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || 'Failed to delete rule');
}
showToast('Rule Deleted', `Proxy rule "${name}" removed`, 'success');
await loadReverseProxy(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleProxyRuleState(id, currentState) {
try {
const res = await fetch(`${API_BASE}/proxy/rules/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !currentState })
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || 'Failed to toggle rule state');
}
await loadReverseProxy(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function openProxyConfigModal() {
const modal = document.getElementById('modal-proxy-config');
if (!modal) return;
const cfg = state.proxyConfig || { enabled: true, http_port: 80, https_port: 443, default_cert: '' };
document.getElementById('proxy-cfg-enabled').checked = cfg.enabled !== false;
document.getElementById('proxy-cfg-http-port').value = cfg.http_port || 80;
document.getElementById('proxy-cfg-https-port').value = cfg.https_port || 443;
populateDefaultProxyCertDropdown(cfg.default_cert || '');
modal.classList.remove('hidden');
}
function closeProxyConfigModal() {
const modal = document.getElementById('modal-proxy-config');
if (modal) modal.classList.add('hidden');
}
async function saveProxyConfig(event) {
event.preventDefault();
const enabled = document.getElementById('proxy-cfg-enabled').checked;
const httpPort = parseInt(document.getElementById('proxy-cfg-http-port').value, 10) || 80;
const httpsPort = parseInt(document.getElementById('proxy-cfg-https-port').value, 10) || 443;
const defaultCert = document.getElementById('proxy-cfg-default-cert').value;
const payload = {
enabled,
http_port: httpPort,
https_port: httpsPort,
default_cert: defaultCert
};
try {
const res = await fetch(`${API_BASE}/proxy/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || 'Failed to update proxy settings');
}
closeProxyConfigModal();
showToast('Success', 'Reverse proxy settings updated', 'success');
await loadReverseProxy(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ==================== SD-WAN Controller ====================
async function loadSDWAN(manual = false) {
try {
const [groupsRes, telemetryRes] = await Promise.all([
fetch(`${API_BASE}/sdwan/groups`),
fetch(`${API_BASE}/sdwan/telemetry`)
]);
if (!groupsRes.ok) throw new Error('Failed to load SD-WAN groups');
state.sdwanGroups = await groupsRes.json() || [];
if (telemetryRes.ok) {
state.sdwanTelemetry = await telemetryRes.json() || {};
}
renderSDWANGroups(state.sdwanGroups);
renderSDWANMembers(state.sdwanGroups);
updateSDWANMetrics(state.sdwanTelemetry);
if (manual) showToast('SD-WAN', 'SD-WAN groups and live telemetry reloaded', 'success');
} catch (err) {
console.error('loadSDWAN error:', err);
if (manual) showToast('Error', err.message, 'error');
}
}
function updateSDWANMetrics(telemetry) {
const groups = state.sdwanGroups || [];
const t = telemetry || {};
const badge = document.getElementById('badge-sdwan-count');
if (badge) badge.textContent = groups.length;
const metricGroups = document.getElementById('sdwan-metric-groups');
if (metricGroups) metricGroups.textContent = `${t.active_groups || 0} / ${t.total_groups || groups.length}`;
const metricHealthy = document.getElementById('sdwan-metric-healthy');
if (metricHealthy) metricHealthy.textContent = t.healthy_members || 0;
const metricDegraded = document.getElementById('sdwan-metric-degraded');
if (metricDegraded) metricDegraded.textContent = t.degraded_members || 0;
const metricDown = document.getElementById('sdwan-metric-down');
if (metricDown) metricDown.textContent = t.down_members || 0;
}
function renderSDWANGroups(groups) {
const tbody = document.getElementById('sdwan-groups-table-body');
if (!tbody) return;
if (!groups || groups.length === 0) {
tbody.innerHTML = '| No SD-WAN groups configured yet. Click "Create SD-WAN Group" to begin. |
';
return;
}
tbody.innerHTML = groups.map(g => {
const isEnabled = g.disabled !== true;
const members = g.members || [];
const totalWeight = members.filter(m => !m.disabled).reduce((sum, m) => sum + (m.weight || 50), 0) || 1;
// Build distribution progress bar
let distHtml = '';
if (members.length === 0) {
distHtml = 'No links attached';
} else if (g.mode === 'weighted' || g.mode === 'pcc') {
const colors = ['bg-amber-500', 'bg-cyan-500', 'bg-emerald-500', 'bg-purple-500', 'bg-rose-500'];
const segments = members.map((m, idx) => {
const pct = m.disabled ? 0 : Math.round(((m.weight || 50) / totalWeight) * 100);
const col = colors[idx % colors.length];
return ``;
}).join('');
const labels = members.map((m, idx) => {
const pct = m.disabled ? '0%' : `${Math.round(((m.weight || 50) / totalWeight) * 100)}%`;
const textCol = ['text-amber-400', 'text-cyan-400', 'text-emerald-400', 'text-purple-400', 'text-rose-400'][idx % 5];
return `${escapeHtml(m.interface)}: ${pct}`;
}).join('');
distHtml = `
${segments}
${labels}
`;
} else if (g.mode === 'lowest-latency') {
distHtml = '⚡ Auto: lowest RTT probe';
} else if (g.mode === 'lowest-loss') {
distHtml = '🛡 Auto: minimum packet loss';
} else if (g.mode === 'failover') {
distHtml = '🔄 Priority Failover (Active-Backup)';
}
let modeBadge = '';
switch (g.mode) {
case 'weighted':
modeBadge = 'Weighted LB';
break;
case 'pcc':
modeBadge = 'PCC Hashing';
break;
case 'lowest-latency':
modeBadge = 'Lowest Latency SLA';
break;
case 'lowest-loss':
modeBadge = 'Lowest Loss SLA';
break;
case 'failover':
modeBadge = 'Failover';
break;
default:
modeBadge = `${escapeHtml(g.mode)}`;
}
const autoNatBadge = g.auto_nat
? 'Enabled'
: 'Disabled';
return `
|
|
${escapeHtml(g.name)}
${g.comment ? `(${escapeHtml(g.comment)})` : ''}
|
${modeBadge} |
${distHtml} |
${members.length} links
|
${g.probe_interval || 3}s |
${autoNatBadge} |
|
`;
}).join('');
}
function renderSDWANMembers(groups) {
const tbody = document.getElementById('sdwan-members-table-body');
if (!tbody) return;
const allMembers = [];
(groups || []).forEach(g => {
(g.members || []).forEach(m => {
allMembers.push({ ...m, group_id: g.id, group_name: g.name });
});
});
if (allMembers.length === 0) {
tbody.innerHTML = '| No member WAN links configured. Click "Add Member Link" above. |
';
return;
}
tbody.innerHTML = allMembers.map(m => {
const isEnabled = m.disabled !== true;
let statusBadge = 'HEALTHY';
if (!isEnabled) {
statusBadge = 'DISABLED';
} else if (m.status === 'DEGRADED') {
statusBadge = 'DEGRADED';
} else if (m.status === 'DOWN') {
statusBadge = 'DOWN';
}
const latColor = m.live_latency > (m.max_latency || 250) ? 'text-amber-400' : 'text-emerald-400';
const lossColor = m.packet_loss > (m.max_loss || 20) ? 'text-rose-400' : 'text-slate-300';
return `
|
|
${escapeHtml(m.group_name)}
|
${escapeHtml(m.interface)}
${m.comment ? `${escapeHtml(m.comment)} ` : ''}
|
${escapeHtml(m.gateway || '(auto)')} |
W: ${m.weight || 50}
Prio: ${m.priority || 1}
|
${statusBadge} |
${escapeHtml(m.target_probe || '1.1.1.1')} |
${m.live_latency ? `${m.live_latency.toFixed(1)} ms` : '--'}
|
${m.packet_loss !== undefined ? `${m.packet_loss.toFixed(1)}%` : '--'}
|
${m.jitter ? `${m.jitter.toFixed(1)} ms` : '--'}
|
|
`;
}).join('');
}
// Modal handling for SD-WAN Groups
function openAddSDWANGroupModal() {
const modal = document.getElementById('modal-sdwan-group');
if (!modal) return;
document.getElementById('sdwan-group-modal-title').querySelector('span').textContent = 'Create SD-WAN Group';
document.getElementById('sdwan-group-id').value = '';
document.getElementById('sdwan-group-name').value = `sdwan${(state.sdwanGroups || []).length}`;
document.getElementById('sdwan-group-name').disabled = false;
document.getElementById('sdwan-group-mode').value = 'weighted';
document.getElementById('sdwan-group-pcc-container').classList.add('hidden');
document.getElementById('sdwan-group-hash-fields').value = 'both-addresses-and-ports';
document.getElementById('sdwan-group-probe-interval').value = 3;
document.getElementById('sdwan-group-auto-nat').checked = true;
document.getElementById('sdwan-group-comment').value = '';
modal.classList.remove('hidden');
}
function openEditSDWANGroupModal(id) {
const group = (state.sdwanGroups || []).find(g => g.id === id);
if (!group) return;
const modal = document.getElementById('modal-sdwan-group');
if (!modal) return;
document.getElementById('sdwan-group-modal-title').querySelector('span').textContent = `Edit SD-WAN Group (${group.name})`;
document.getElementById('sdwan-group-id').value = group.id;
document.getElementById('sdwan-group-name').value = group.name;
document.getElementById('sdwan-group-name').disabled = true;
document.getElementById('sdwan-group-mode').value = group.mode || 'weighted';
if (group.mode === 'pcc') {
document.getElementById('sdwan-group-pcc-container').classList.remove('hidden');
} else {
document.getElementById('sdwan-group-pcc-container').classList.add('hidden');
}
document.getElementById('sdwan-group-hash-fields').value = group.hash_fields || 'both-addresses-and-ports';
document.getElementById('sdwan-group-probe-interval').value = group.probe_interval || 3;
document.getElementById('sdwan-group-auto-nat').checked = group.auto_nat !== false;
document.getElementById('sdwan-group-comment').value = group.comment || '';
modal.classList.remove('hidden');
}
function closeSDWANGroupModal() {
const modal = document.getElementById('modal-sdwan-group');
if (modal) modal.classList.add('hidden');
}
function onSDWANModeChange() {
const mode = document.getElementById('sdwan-group-mode').value;
const pccContainer = document.getElementById('sdwan-group-pcc-container');
if (pccContainer) {
if (mode === 'pcc') pccContainer.classList.remove('hidden');
else pccContainer.classList.add('hidden');
}
}
async function saveSDWANGroup(event) {
event.preventDefault();
const id = document.getElementById('sdwan-group-id').value;
const name = document.getElementById('sdwan-group-name').value.trim();
const mode = document.getElementById('sdwan-group-mode').value;
const hashFields = document.getElementById('sdwan-group-hash-fields').value;
const probeInterval = parseInt(document.getElementById('sdwan-group-probe-interval').value, 10) || 3;
const autoNAT = document.getElementById('sdwan-group-auto-nat').checked;
const comment = document.getElementById('sdwan-group-comment').value.trim();
const existing = (state.sdwanGroups || []).find(g => g.id === id);
const payload = {
id: id || undefined,
name,
mode,
hash_fields: mode === 'pcc' ? hashFields : undefined,
probe_interval: probeInterval,
auto_nat: autoNAT,
comment,
members: existing ? existing.members : undefined
};
try {
const res = await fetch(`${API_BASE}/sdwan/groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to save SD-WAN group');
}
closeSDWANGroupModal();
showToast('Success', `SD-WAN group ${name} saved successfully`, 'success');
await loadSDWAN(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleSDWANGroupState(id, currentState) {
try {
const res = await fetch(`${API_BASE}/sdwan/groups/${id}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled: currentState })
});
if (!res.ok) throw new Error('Failed to update group state');
await loadSDWAN(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteSDWANGroup(id) {
const group = (state.sdwanGroups || []).find(g => g.id === id);
const name = group ? group.name : id;
if (!confirm(`Are you sure you want to delete SD-WAN group "${name}"? Member routing tables will be cleared.`)) return;
try {
const res = await fetch(`${API_BASE}/sdwan/groups/${id}`, {
method: 'DELETE'
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to delete SD-WAN group');
}
showToast('Deleted', `SD-WAN group ${name} removed`, 'success');
await loadSDWAN(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// Modal handling for SD-WAN Members
function populateSDWANGroupsSelect(selectedId = '') {
const sel = document.getElementById('sdwan-member-group-id');
if (!sel) return;
sel.innerHTML = '';
(state.sdwanGroups || []).forEach(g => {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = `${g.name} (${g.mode})`;
if (g.id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
}
function populateSDWANMemberInterfaceSelect(selectedVal = '') {
const sel = document.getElementById('sdwan-member-interface');
if (!sel) return;
sel.innerHTML = '';
(state.interfaces || []).forEach(i => {
const opt = document.createElement('option');
opt.value = i.name;
opt.textContent = `${i.name} (${i.role || i.type})`;
if (i.name === selectedVal) opt.selected = true;
sel.appendChild(opt);
});
}
function openAddSDWANMemberModal(defaultGroupId = null) {
const modal = document.getElementById('modal-sdwan-member');
if (!modal) return;
if (!state.sdwanGroups || state.sdwanGroups.length === 0) {
showToast('Notice', 'Please create an SD-WAN group first before adding member links', 'warning');
return;
}
document.getElementById('sdwan-member-modal-title').querySelector('span').textContent = 'Add Member Link';
document.getElementById('sdwan-member-id').value = '';
populateSDWANGroupsSelect(defaultGroupId || (state.sdwanGroups[0] && state.sdwanGroups[0].id));
populateSDWANMemberInterfaceSelect();
document.getElementById('sdwan-member-gateway').value = '';
document.getElementById('sdwan-member-weight').value = 50;
document.getElementById('sdwan-member-priority').value = 1;
document.getElementById('sdwan-member-probe').value = '1.1.1.1';
document.getElementById('sdwan-member-max-lat').value = 250;
document.getElementById('sdwan-member-max-loss').value = 20;
document.getElementById('sdwan-member-comment').value = '';
modal.classList.remove('hidden');
}
function openEditSDWANMemberModal(groupId, memberId) {
const group = (state.sdwanGroups || []).find(g => g.id === groupId);
if (!group) return;
const member = (group.members || []).find(m => m.id === memberId);
if (!member) return;
const modal = document.getElementById('modal-sdwan-member');
if (!modal) return;
document.getElementById('sdwan-member-modal-title').querySelector('span').textContent = `Edit Member Link (${member.interface})`;
document.getElementById('sdwan-member-id').value = member.id;
populateSDWANGroupsSelect(group.id);
document.getElementById('sdwan-member-group-id').disabled = true;
populateSDWANMemberInterfaceSelect(member.interface);
document.getElementById('sdwan-member-interface').disabled = true;
document.getElementById('sdwan-member-gateway').value = member.gateway || '';
document.getElementById('sdwan-member-weight').value = member.weight || 50;
document.getElementById('sdwan-member-priority').value = member.priority || 1;
document.getElementById('sdwan-member-probe').value = member.target_probe || '1.1.1.1';
document.getElementById('sdwan-member-max-lat').value = member.max_latency || 250;
document.getElementById('sdwan-member-max-loss').value = member.max_loss || 20;
document.getElementById('sdwan-member-comment').value = member.comment || '';
modal.classList.remove('hidden');
}
function closeSDWANMemberModal() {
const modal = document.getElementById('modal-sdwan-member');
if (modal) {
document.getElementById('sdwan-member-group-id').disabled = false;
document.getElementById('sdwan-member-interface').disabled = false;
modal.classList.add('hidden');
}
}
async function saveSDWANMember(event) {
event.preventDefault();
const id = document.getElementById('sdwan-member-id').value;
let groupId = document.getElementById('sdwan-member-group-id').value;
let iface = document.getElementById('sdwan-member-interface').value;
// In edit mode with disabled selects, fallback to existing member data if needed
if (id) {
const existingGroup = (state.sdwanGroups || []).find(g => (g.members || []).some(m => m.id === id));
if (existingGroup) {
if (!groupId) groupId = existingGroup.id;
const mem = (existingGroup.members || []).find(m => m.id === id);
if (mem && !iface) iface = mem.interface;
}
}
const gateway = document.getElementById('sdwan-member-gateway').value.trim();
const weight = parseInt(document.getElementById('sdwan-member-weight').value, 10) || 50;
const priority = parseInt(document.getElementById('sdwan-member-priority').value, 10) || 1;
const targetProbe = document.getElementById('sdwan-member-probe').value.trim() || '1.1.1.1';
const maxLatency = parseInt(document.getElementById('sdwan-member-max-lat').value, 10) || 250;
const maxLoss = parseInt(document.getElementById('sdwan-member-max-loss').value, 10) || 20;
const comment = document.getElementById('sdwan-member-comment').value.trim();
const payload = {
id: id || undefined,
interface: iface,
gateway,
weight,
priority,
target_probe: targetProbe,
max_latency: maxLatency,
max_loss: maxLoss,
comment
};
try {
const method = id ? 'PUT' : 'POST';
const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to save member link');
}
closeSDWANMemberModal();
showToast('Success', `Link ${iface} saved to SD-WAN group`, 'success');
await loadSDWAN(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleSDWANMemberState(groupId, memberId, currentState) {
const group = (state.sdwanGroups || []).find(g => g.id === groupId);
if (!group) return;
const member = (group.members || []).find(m => m.id === memberId);
if (!member) return;
const payload = {
...member,
disabled: currentState
};
try {
const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to update member state');
await loadSDWAN(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteSDWANMember(groupId, iface) {
if (!confirm(`Are you sure you want to remove link "${iface}" from this SD-WAN group?`)) return;
try {
const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members/${iface}`, {
method: 'DELETE'
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to remove member link');
}
showToast('Removed', `Link ${iface} removed from SD-WAN group`, 'success');
await loadSDWAN(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ==============================================================================
// DYNAMIC ROUTING: RIP MODULE
// ==============================================================================
state.ripInstances = [];
state.ripInterfaces = [];
state.ripNeighborsLive = [];
state.ripRoutes = [];
state.selectedRipRow = {};
state.activeRipTab = 'instances';
function switchRipTab(tab) {
state.activeRipTab = tab;
['instances', 'interfaces', 'neighbors', 'routes'].forEach(t => {
const pane = document.getElementById(`rip-pane-${t}`);
const btn = document.getElementById(`rip-tab-btn-${t}`);
if (pane) {
if (t === tab) pane.classList.remove('hidden');
else pane.classList.add('hidden');
}
if (btn) {
if (t === tab) {
btn.className = 'px-3 py-2 text-xs font-semibold border-b-2 border-rose-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 selectRipRow(tab, id) {
if (!state.selectedRipRow) state.selectedRipRow = {};
state.selectedRipRow[tab] = id;
if (tab === 'instances') {
renderRipInstances();
const btnEn = document.getElementById('btn-rip-inst-enable');
const btnDis = document.getElementById('btn-rip-inst-disable');
const btnRem = document.getElementById('btn-rip-inst-remove');
if (btnEn) btnEn.disabled = false;
if (btnDis) btnDis.disabled = false;
if (btnRem) btnRem.disabled = false;
} else if (tab === 'interfaces') {
renderRipInterfaces();
const btnEn = document.getElementById('btn-rip-iface-enable');
const btnDis = document.getElementById('btn-rip-iface-disable');
const btnRem = document.getElementById('btn-rip-iface-remove');
if (btnEn) btnEn.disabled = false;
if (btnDis) btnDis.disabled = false;
if (btnRem) btnRem.disabled = false;
}
}
function renderRipInstances() {
const tbody = document.getElementById('rip-instances-table-body');
if (!tbody) return;
const countEl = document.getElementById('rip-instances-footer-count');
const filter = (document.getElementById('filter-rip-instances')?.value || '').toLowerCase();
let list = state.ripInstances || [];
if (filter) {
list = list.filter(i => (i.name || '').toLowerCase().includes(filter) || (i.comment || '').toLowerCase().includes(filter));
}
if (countEl) countEl.innerText = list.length;
const selId = state.selectedRipRow?.instances;
tbody.innerHTML = list.map((inst, idx) => {
const isSel = inst.id === selId;
const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-rose-400' : 'hover:bg-[#1a2636] text-slate-300';
const flagD = inst.disabled
? 'X'
: (inst.running
? 'R'
: 'I');
const redist = [];
if (inst.redistribute_connected) redist.push('conn');
if (inst.redistribute_static) redist.push('static');
if (inst.redistribute_bgp) redist.push('bgp');
if (inst.redistribute_ospf) redist.push('ospf');
if (inst.redistribute_default) redist.push('default');
return `
| ${idx} |
${flagD} |
${escapeHtml(inst.comment || '')} |
↳ ${escapeHtml(inst.name || 'default')}
|
${escapeHtml(inst.version || 'rip2')} |
${inst.update_time || 30}s |
${inst.timeout_time || 180}s |
${redist.join(', ') || 'none'} |
${inst.default_metric || 1} |
`;
}).join('');
}
function renderRipInterfaces() {
const tbody = document.getElementById('rip-interfaces-table-body');
if (!tbody) return;
const countEl = document.getElementById('rip-interfaces-footer-count');
const filter = (document.getElementById('filter-rip-interfaces')?.value || '').toLowerCase();
let list = state.ripInterfaces || [];
if (filter) {
list = list.filter(i => (i.interface || '').toLowerCase().includes(filter) || (i.comment || '').toLowerCase().includes(filter));
}
if (countEl) countEl.innerText = list.length;
const selId = state.selectedRipRow?.interfaces;
tbody.innerHTML = list.map((iface, idx) => {
const isSel = iface.id === selId;
const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-rose-400' : 'hover:bg-[#1a2636] text-slate-300';
const flagD = iface.disabled ? 'X' : 'R';
return `
| ${idx} |
${flagD} |
${escapeHtml(iface.comment || '')} |
${escapeHtml(iface.interface || '*')} |
${escapeHtml(iface.mode || 'multicast')} |
${iface.metric || 1} |
${iface.passive ? 'yes' : 'no'} |
${iface.use_bfd ? 'yes' : 'no'} |
`;
}).join('');
}
function renderRipNeighbors() {
const tbody = document.getElementById('rip-neighbors-table-body');
if (!tbody) return;
const countEl = document.getElementById('rip-neighbors-footer-count');
const list = state.ripNeighborsLive || [];
if (countEl) countEl.innerText = list.length;
tbody.innerHTML = list.map((nbr, idx) => `
| ${idx} |
${escapeHtml(nbr.address || '')} |
${escapeHtml(nbr.interface || '')} |
${escapeHtml(nbr.last_seen || '')} |
${escapeHtml(nbr.uptime || '')} |
`).join('');
}
function renderRipRoutes() {
const tbody = document.getElementById('rip-routes-table-body');
if (!tbody) return;
const countEl = document.getElementById('rip-routes-footer-count');
const list = state.ripRoutes || [];
if (countEl) countEl.innerText = list.length;
tbody.innerHTML = list.map((r, idx) => `
| ${idx} |
${escapeHtml(r.prefix || '')} |
${escapeHtml(r.next_hop || '')} |
${r.metric || 1} |
${escapeHtml(r.interface || '')} |
${escapeHtml(r.uptime || '')} |
`).join('');
}
function openRipInstanceModal() {
document.getElementById('rip-instance-modal-title').innerText = 'New RIP Instance';
document.getElementById('modal-rip-instance-id').value = '';
document.getElementById('modal-rip-instance-name').value = 'rip1';
document.getElementById('modal-rip-instance-version').value = 'rip2';
document.getElementById('modal-rip-instance-update').value = '30';
document.getElementById('modal-rip-instance-timeout').value = '180';
document.getElementById('modal-rip-instance-garbage').value = '120';
document.getElementById('modal-rip-instance-metric').value = '1';
document.getElementById('modal-rip-instance-comment').value = '';
document.getElementById('modal-rip-instance-enabled').checked = true;
document.getElementById('modal-rip-instance-redist-conn').checked = false;
document.getElementById('modal-rip-instance-redist-static').checked = false;
document.getElementById('modal-rip-instance-redist-bgp').checked = false;
document.getElementById('modal-rip-instance-redist-ospf').checked = false;
document.getElementById('modal-rip-instance-redist-default').checked = false;
document.getElementById('rip-instance-modal')?.classList.remove('hidden');
}
function openEditRipInstanceModal(id) {
const inst = (state.ripInstances || []).find(i => i.id === id);
if (!inst) return;
document.getElementById('rip-instance-modal-title').innerText = `Edit RIP Instance: ${inst.name}`;
document.getElementById('modal-rip-instance-id').value = inst.id;
document.getElementById('modal-rip-instance-name').value = inst.name || '';
document.getElementById('modal-rip-instance-version').value = inst.version || 'rip2';
document.getElementById('modal-rip-instance-update').value = inst.update_time || 30;
document.getElementById('modal-rip-instance-timeout').value = inst.timeout_time || 180;
document.getElementById('modal-rip-instance-garbage').value = inst.garbage_time || 120;
document.getElementById('modal-rip-instance-metric').value = inst.default_metric || 1;
document.getElementById('modal-rip-instance-comment').value = inst.comment || '';
document.getElementById('modal-rip-instance-enabled').checked = !inst.disabled;
document.getElementById('modal-rip-instance-redist-conn').checked = !!inst.redistribute_connected;
document.getElementById('modal-rip-instance-redist-static').checked = !!inst.redistribute_static;
document.getElementById('modal-rip-instance-redist-bgp').checked = !!inst.redistribute_bgp;
document.getElementById('modal-rip-instance-redist-ospf').checked = !!inst.redistribute_ospf;
document.getElementById('modal-rip-instance-redist-default').checked = !!inst.redistribute_default;
document.getElementById('rip-instance-modal')?.classList.remove('hidden');
}
function closeRipInstanceModal() {
document.getElementById('rip-instance-modal')?.classList.add('hidden');
}
async function submitRipInstance(e) {
e.preventDefault();
const id = document.getElementById('modal-rip-instance-id').value;
const payload = {
id: id || undefined,
name: document.getElementById('modal-rip-instance-name').value.trim(),
version: document.getElementById('modal-rip-instance-version').value,
update_time: parseInt(document.getElementById('modal-rip-instance-update').value, 10) || 30,
timeout_time: parseInt(document.getElementById('modal-rip-instance-timeout').value, 10) || 180,
garbage_time: parseInt(document.getElementById('modal-rip-instance-garbage').value, 10) || 120,
default_metric: parseInt(document.getElementById('modal-rip-instance-metric').value, 10) || 1,
comment: document.getElementById('modal-rip-instance-comment').value.trim(),
disabled: !document.getElementById('modal-rip-instance-enabled').checked,
redistribute_connected: document.getElementById('modal-rip-instance-redist-conn').checked,
redistribute_static: document.getElementById('modal-rip-instance-redist-static').checked,
redistribute_bgp: document.getElementById('modal-rip-instance-redist-bgp').checked,
redistribute_ospf: document.getElementById('modal-rip-instance-redist-ospf').checked,
redistribute_default: document.getElementById('modal-rip-instance-redist-default').checked
};
try {
const res = await fetch(`${API_BASE}/routing/rip/instances`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
closeRipInstanceModal();
showToast('Saved', `RIP instance ${payload.name} saved`, 'success');
await loadDynamicRouting(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function enableSelectedRipInstance() {
const id = state.selectedRipRow?.instances;
if (!id) return;
await fetch(`${API_BASE}/routing/rip/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
await loadDynamicRouting(false);
}
async function disableSelectedRipInstance() {
const id = state.selectedRipRow?.instances;
if (!id) return;
await fetch(`${API_BASE}/routing/rip/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
await loadDynamicRouting(false);
}
async function removeSelectedRipInstance() {
const id = state.selectedRipRow?.instances;
if (!id || !confirm('Are you sure you want to delete this RIP instance?')) return;
await fetch(`${API_BASE}/routing/rip/instances/${id}`, { method: 'DELETE' });
state.selectedRipRow.instances = null;
await loadDynamicRouting(false);
}
function openRipInterfaceModal() {
document.getElementById('rip-interface-modal-title').innerText = 'New RIP Interface';
document.getElementById('modal-rip-iface-id').value = '';
const dl = document.getElementById('rip-iface-datalist');
if (dl) {
dl.innerHTML = '`).join('');
if (instSel.innerHTML === '') instSel.innerHTML = '';
}
document.getElementById('modal-rip-iface-pattern').value = '*';
if (instSel) instSel.value = (state.ripInstances && state.ripInstances.length > 0) ? state.ripInstances[0].name : 'default';
document.getElementById('modal-rip-iface-mode').value = 'multicast';
document.getElementById('modal-rip-iface-metric').value = '1';
document.getElementById('modal-rip-iface-comment').value = '';
document.getElementById('modal-rip-iface-enabled').checked = true;
document.getElementById('modal-rip-iface-passive').checked = false;
document.getElementById('modal-rip-iface-bfd').checked = false;
document.getElementById('rip-interface-modal')?.classList.remove('hidden');
}
function openEditRipInterfaceModal(id) {
const iface = (state.ripInterfaces || []).find(i => i.id === id);
if (!iface) return;
document.getElementById('rip-interface-modal-title').innerText = `Edit RIP Interface: ${iface.interface}`;
document.getElementById('modal-rip-iface-id').value = iface.id;
const dl = document.getElementById('rip-iface-datalist');
if (dl) {
dl.innerHTML = '`).join('');
if (instSel.innerHTML === '') instSel.innerHTML = '';
instSel.value = iface.instance || 'default';
} else if (instSel) {
instSel.value = iface.instance || '';
}
document.getElementById('modal-rip-iface-pattern').value = iface.interface || '*';
document.getElementById('modal-rip-iface-mode').value = iface.mode || 'multicast';
document.getElementById('modal-rip-iface-metric').value = iface.metric || 1;
document.getElementById('modal-rip-iface-comment').value = iface.comment || '';
document.getElementById('modal-rip-iface-enabled').checked = !iface.disabled;
document.getElementById('modal-rip-iface-passive').checked = !!iface.passive;
document.getElementById('modal-rip-iface-bfd').checked = !!iface.use_bfd;
document.getElementById('rip-interface-modal')?.classList.remove('hidden');
}
function closeRipInterfaceModal() {
document.getElementById('rip-interface-modal')?.classList.add('hidden');
}
async function submitRipInterface(e) {
e.preventDefault();
const id = document.getElementById('modal-rip-iface-id').value;
const payload = {
id: id || undefined,
interface: document.getElementById('modal-rip-iface-pattern').value.trim(),
instance: document.getElementById('modal-rip-iface-instance').value.trim(),
mode: document.getElementById('modal-rip-iface-mode').value,
metric: parseInt(document.getElementById('modal-rip-iface-metric').value, 10) || 1,
comment: document.getElementById('modal-rip-iface-comment').value.trim(),
disabled: !document.getElementById('modal-rip-iface-enabled').checked,
passive: document.getElementById('modal-rip-iface-passive').checked,
use_bfd: document.getElementById('modal-rip-iface-bfd').checked
};
try {
const res = await fetch(`${API_BASE}/routing/rip/interfaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
closeRipInterfaceModal();
showToast('Saved', `RIP interface template saved`, 'success');
await loadDynamicRouting(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function enableSelectedRipInterface() {
const id = state.selectedRipRow?.interfaces;
if (!id) return;
await fetch(`${API_BASE}/routing/rip/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
await loadDynamicRouting(false);
}
async function disableSelectedRipInterface() {
const id = state.selectedRipRow?.interfaces;
if (!id) return;
await fetch(`${API_BASE}/routing/rip/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
await loadDynamicRouting(false);
}
async function removeSelectedRipInterface() {
const id = state.selectedRipRow?.interfaces;
if (!id || !confirm('Are you sure you want to delete this RIP interface template?')) return;
await fetch(`${API_BASE}/routing/rip/interfaces/${id}`, { method: 'DELETE' });
state.selectedRipRow.interfaces = null;
await loadDynamicRouting(false);
}
// ==============================================================================
// DYNAMIC ROUTING: BFD MODULE
// ==============================================================================
state.bfdInstance = null;
state.bfdInterfaces = [];
state.bfdNeighbors = [];
state.bfdSessions = [];
state.selectedBfdRow = {};
state.activeBfdTab = 'config';
function switchBfdTab(tab) {
state.activeBfdTab = tab;
['config', 'interfaces', 'peers', 'sessions'].forEach(t => {
const pane = document.getElementById(`bfd-pane-${t}`);
const btn = document.getElementById(`bfd-tab-btn-${t}`);
if (pane) {
if (t === tab) pane.classList.remove('hidden');
else pane.classList.add('hidden');
}
if (btn) {
if (t === tab) {
btn.className = 'px-3 py-2 text-xs font-semibold border-b-2 border-amber-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 selectBfdRow(tab, id) {
if (!state.selectedBfdRow) state.selectedBfdRow = {};
state.selectedBfdRow[tab] = id;
if (tab === 'interfaces') {
renderBfdInterfaces();
const btnEn = document.getElementById('btn-bfd-iface-enable');
const btnDis = document.getElementById('btn-bfd-iface-disable');
const btnRem = document.getElementById('btn-bfd-iface-remove');
if (btnEn) btnEn.disabled = false;
if (btnDis) btnDis.disabled = false;
if (btnRem) btnRem.disabled = false;
} else if (tab === 'peers') {
renderBfdPeers();
const btnEn = document.getElementById('btn-bfd-peer-enable');
const btnDis = document.getElementById('btn-bfd-peer-disable');
const btnRem = document.getElementById('btn-bfd-peer-remove');
if (btnEn) btnEn.disabled = false;
if (btnDis) btnDis.disabled = false;
if (btnRem) btnRem.disabled = false;
}
}
function renderBfdConfig() {
const inst = state.bfdInstance;
const pill = document.getElementById('bfd-live-status-pill');
const toggleBtn = document.getElementById('bfd-global-toggle-btn');
const ifaceStat = document.getElementById('bfd-stat-interfaces');
const peerStat = document.getElementById('bfd-stat-peers');
const sessStat = document.getElementById('bfd-stat-sessions');
if (ifaceStat) ifaceStat.innerText = (state.bfdInterfaces || []).length;
if (peerStat) peerStat.innerText = (state.bfdNeighbors || []).length;
if (sessStat) sessStat.innerText = (state.bfdSessions || []).length;
const isEnabled = inst && (inst.enabled || inst.running || (!inst.disabled && (state.bfdInterfaces || []).length > 0));
if (!inst || inst.disabled || !isEnabled) {
if (pill) {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
pill.innerText = 'DISABLED';
}
if (toggleBtn) {
toggleBtn.innerText = 'Enable BFD';
toggleBtn.className = 'px-4 py-2 rounded-lg text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-500/20 transition';
}
} else {
if (pill) {
if (inst.running) {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-emerald-950 text-emerald-300 border border-emerald-800/60 font-semibold';
pill.innerText = 'RUNNING (ACTIVE)';
} else {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
pill.innerText = 'CONFIGURED';
}
}
if (toggleBtn) {
toggleBtn.innerText = 'Disable BFD';
toggleBtn.className = 'px-4 py-2 rounded-lg text-xs font-semibold bg-rose-600/80 hover:bg-rose-600 text-white transition';
}
}
}
async function toggleBfdGlobalState() {
const current = state.bfdInstance;
const isCurrentlyEnabled = current && (current.enabled || current.running) && !current.disabled;
const newEnabled = !isCurrentlyEnabled;
const newDisabled = !newEnabled;
try {
const res = await fetch(`${API_BASE}/routing/bfd/instance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newEnabled, disabled: newDisabled })
});
if (!res.ok) throw new Error(await res.text());
showToast('Success', `BFD protocol ${newDisabled ? 'disabled' : 'enabled'}`, 'success');
await loadDynamicRouting(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function renderBfdInterfaces() {
const tbody = document.getElementById('bfd-interfaces-table-body');
if (!tbody) return;
const countEl = document.getElementById('bfd-interfaces-footer-count');
const filter = (document.getElementById('filter-bfd-interfaces')?.value || '').toLowerCase();
let list = state.bfdInterfaces || [];
if (filter) {
list = list.filter(i => (i.interface || '').toLowerCase().includes(filter) || (i.comment || '').toLowerCase().includes(filter));
}
if (countEl) countEl.innerText = list.length;
const selId = state.selectedBfdRow?.interfaces;
tbody.innerHTML = list.map((iface, idx) => {
const isSel = iface.id === selId;
const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-amber-400' : 'hover:bg-[#1a2636] text-slate-300';
const flagD = iface.disabled ? 'X' : 'B';
return `
| ${idx} |
${flagD} |
${escapeHtml(iface.comment || '')} |
${escapeHtml(iface.interface || '*')} |
${iface.min_rx_interval || 100} ms |
${iface.min_tx_interval || 100} ms |
${iface.idle_tx_interval || 1000} ms |
${iface.multiplier || 3} |
${iface.passive ? 'yes' : 'no'} |
`;
}).join('');
}
function renderBfdPeers() {
const tbody = document.getElementById('bfd-peers-table-body');
if (!tbody) return;
const countEl = document.getElementById('bfd-peers-footer-count');
const filter = (document.getElementById('filter-bfd-peers')?.value || '').toLowerCase();
let list = state.bfdNeighbors || [];
if (filter) {
list = list.filter(p => (p.address || '').toLowerCase().includes(filter) || (p.comment || '').toLowerCase().includes(filter));
}
if (countEl) countEl.innerText = list.length;
const selId = state.selectedBfdRow?.peers;
tbody.innerHTML = list.map((p, idx) => {
const isSel = p.id === selId;
const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-amber-400' : 'hover:bg-[#1a2636] text-slate-300';
const flagD = p.disabled ? 'X' : 'B';
return `
| ${idx} |
${flagD} |
${escapeHtml(p.comment || '')} |
${escapeHtml(p.address || '')} |
${escapeHtml(p.interface || 'auto')} |
${escapeHtml(p.local_address || 'auto')} |
${p.multihop ? 'yes' : 'no'} |
`;
}).join('');
}
function renderBfdSessions() {
const tbody = document.getElementById('bfd-sessions-table-body');
if (!tbody) return;
const countEl = document.getElementById('bfd-sessions-footer-count');
const list = state.bfdSessions || [];
if (countEl) countEl.innerText = list.length;
tbody.innerHTML = list.map((s, idx) => {
const isUp = (s.state || '').toLowerCase() === 'up';
const stateBadge = isUp
? 'UP'
: `${escapeHtml(s.state || 'DOWN')}`;
return `
| ${idx} |
${escapeHtml(s.ip || '')} |
${escapeHtml(s.interface || '')} |
${stateBadge} |
${escapeHtml(s.interval || '')} |
${escapeHtml(s.timeout || '')} |
${escapeHtml(s.since || '')} |
`;
}).join('');
}
function openBfdInterfaceModal() {
document.getElementById('bfd-interface-modal-title').innerText = 'New BFD Interface Profile';
document.getElementById('modal-bfd-iface-id').value = '';
const dl = document.getElementById('bfd-iface-datalist');
if (dl) {
dl.innerHTML = '' +
(state.babelInstances || []).map(i => ``).join('');
if (state.babelInstances && state.babelInstances.length > 0) {
instSel.value = state.babelInstances[0].name;
}
}
const dl = document.getElementById('babel-iface-datalist');
if (dl) {
dl.innerHTML = '' +
(state.babelInstances || []).map(i => ``).join('');
instSel.value = iface.instance || 'all';
}
const dl = document.getElementById('babel-iface-datalist');
if (dl) {
dl.innerHTML = '`).join('');
if (instSel.innerHTML === '') instSel.innerHTML = '';
instSel.value = (state.radvInstances && state.radvInstances.length > 0) ? state.radvInstances[0].name : 'default';
} else if (instSel) {
instSel.value = 'default';
}
document.getElementById('modal-radv-iface-interface').value = '*';
document.getElementById('modal-radv-iface-min-ra').value = '200';
document.getElementById('modal-radv-iface-max-ra').value = '600';
document.getElementById('modal-radv-iface-lifetime').value = '1800';
document.getElementById('modal-radv-iface-hoplimit').value = '64';
document.getElementById('modal-radv-iface-prefixes').value = '';
document.getElementById('modal-radv-iface-rdnss').value = '';
document.getElementById('modal-radv-iface-comment').value = '';
document.getElementById('modal-radv-iface-enabled').checked = true;
document.getElementById('radv-interface-modal')?.classList.remove('hidden');
}
function openEditRAdvInterfaceModal(id) {
const iface = (state.radvInterfaces || []).find(i => i.id === id);
if (!iface) return;
document.getElementById('radv-interface-modal-title').innerText = `Edit RAdv Interface: ${iface.interface}`;
document.getElementById('modal-radv-iface-id').value = iface.id;
const dl = document.getElementById('radv-iface-datalist');
if (dl) {
dl.innerHTML = '`).join('');
if (instSel.innerHTML === '') instSel.innerHTML = '';
instSel.value = iface.instance || 'default';
} else if (instSel) {
instSel.value = iface.instance || '';
}
document.getElementById('modal-radv-iface-interface').value = iface.interface || '*';
document.getElementById('modal-radv-iface-min-ra').value = iface.min_ra_interval || 200;
document.getElementById('modal-radv-iface-max-ra').value = iface.max_ra_interval || 600;
document.getElementById('modal-radv-iface-lifetime').value = iface.default_lifetime || 1800;
document.getElementById('modal-radv-iface-hoplimit').value = iface.hop_limit || 64;
document.getElementById('modal-radv-iface-prefixes').value = (iface.prefixes || []).join(', ');
document.getElementById('modal-radv-iface-rdnss').value = (iface.rdnss || []).join(', ');
document.getElementById('modal-radv-iface-comment').value = iface.comment || '';
document.getElementById('modal-radv-iface-enabled').checked = !iface.disabled;
document.getElementById('radv-interface-modal')?.classList.remove('hidden');
}
function closeRAdvInterfaceModal() {
document.getElementById('radv-interface-modal')?.classList.add('hidden');
}
async function submitRAdvInterface(e) {
e.preventDefault();
const id = document.getElementById('modal-radv-iface-id').value;
const pRaw = document.getElementById('modal-radv-iface-prefixes').value;
const rRaw = document.getElementById('modal-radv-iface-rdnss').value;
const prefixes = pRaw ? pRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
const rdnss = rRaw ? rRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
const payload = {
id: id || undefined,
interface: document.getElementById('modal-radv-iface-interface').value.trim(),
instance: document.getElementById('modal-radv-iface-instance').value.trim(),
min_ra_interval: parseInt(document.getElementById('modal-radv-iface-min-ra').value, 10) || 200,
max_ra_interval: parseInt(document.getElementById('modal-radv-iface-max-ra').value, 10) || 600,
default_lifetime: parseInt(document.getElementById('modal-radv-iface-lifetime').value, 10) || 1800,
hop_limit: parseInt(document.getElementById('modal-radv-iface-hoplimit').value, 10) || 64,
prefixes: prefixes,
rdnss: rdnss,
comment: document.getElementById('modal-radv-iface-comment').value.trim(),
disabled: !document.getElementById('modal-radv-iface-enabled').checked
};
try {
const res = await fetch(`${API_BASE}/routing/radv/interfaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
closeRAdvInterfaceModal();
showToast('Saved', `RAdv interface saved`, 'success');
await loadDynamicRouting(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function enableSelectedRadvInterface() {
const id = state.selectedRadvRow?.interfaces;
if (!id) return;
await fetch(`${API_BASE}/routing/radv/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
await loadDynamicRouting(false);
}
async function disableSelectedRadvInterface() {
const id = state.selectedRadvRow?.interfaces;
if (!id) return;
await fetch(`${API_BASE}/routing/radv/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
await loadDynamicRouting(false);
}
async function removeSelectedRadvInterface() {
const id = state.selectedRadvRow?.interfaces;
if (!id || !confirm('Are you sure you want to remove this RAdv interface?')) return;
await fetch(`${API_BASE}/routing/radv/interfaces/${id}`, { method: 'DELETE' });
state.selectedRadvRow.interfaces = null;
await loadDynamicRouting(false);
}